From f9a0c718782858f887f8c73fd37cc8d61a8d055c Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 05:10:51 -0700
Subject: [PATCH 1/6] Hosted: checkout consent, founders row, and StripeDev in
the dev loop
Checkout now refuses to start without agreement to the current terms
version and to automatic renewal, and records both, with the plan and
Price it offered, before answering the Stripe URL
(dormouse_checkout_consents, kept as a billing record). PUT
/api/billing/founder joins or leaves the founders row
(dormouse_founders): joining needs a current founding subscription
(subscribedSql gains its prices argument) and a checked name; leaving
always works. GET /api/billing adds the price, founder state, and the
recorded consent. dor tool hosted now runs billing on StripeDev.
Co-Authored-By: Claude Opus 5.5
---
hosted/server/billing-routes.ts | 81 +++++++++--
hosted/server/billing.ts | 13 +-
hosted/server/dev.ts | 20 ++-
.../dormouse-migrations/015_founders.sql | 18 +++
.../016_checkout_consents.sql | 20 +++
hosted/server/entitlement.ts | 8 +-
hosted/server/policy-constants.ts | 16 ++
hosted/server/tests/billing-routes.test.ts | 137 +++++++++++++++++-
hosted/server/tests/migrations.test.ts | 4 +
hosted/server/voice.ts | 3 +-
10 files changed, 297 insertions(+), 23 deletions(-)
create mode 100644 hosted/server/dormouse-migrations/015_founders.sql
create mode 100644 hosted/server/dormouse-migrations/016_checkout_consents.sql
diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts
index 8acbf4613..054c8f347 100644
--- a/hosted/server/billing-routes.ts
+++ b/hosted/server/billing-routes.ts
@@ -6,11 +6,13 @@ import { bodyLimit } from "hono/body-limit";
import { CompiledQuery, type Kysely } from "kysely";
import { readJson } from "remote-lib-common";
import { accountQuery, cookieLogin, type AccountHost } from "./account-gate";
-import { REFUND_DAYS, foundingCohort, withBilling, type BillingSetup, type Plan } from "./billing";
-import { CURRENT_STATUSES, entitledSql } from "./entitlement";
+import { REFUND_DAYS, foundingCohort, publishedPrice, withBilling, type BillingSetup, type Plan } from "./billing";
+import { CURRENT_STATUSES, entitledSql, subscribedSql } from "./entitlement";
import { checkoutRef, type MetricEvent } from "./metric-labels";
import { countMetricInTransaction, recordMetric } from "./metrics";
-import { BILLING_PLANS } from "./policy-constants";
+import { BILLING_PLANS, CHECKOUT_CLOSED, TERMS_VERSION, isFounderName } from "./policy-constants";
+
+export { CHECKOUT_CLOSED };
/** What one request's account deployment provides to the billing routes. */
export interface BillingHost extends AccountHost {
@@ -18,8 +20,14 @@ export interface BillingHost extends AccountHost {
setup(): BillingSetup | null;
}
-/** Every billing route's answer while billing is closed. */
-export const CHECKOUT_CLOSED = "Checkout is not open yet.";
+/** Checkout's answer to a request that does not carry both agreements. */
+export const CONSENT_REQUIRED = "Agree to the terms and to automatic renewal first.";
+
+/** Checkout's answer to agreement with a terms version no longer current. */
+export const TERMS_CHANGED = "The terms have changed. Reload this page to read them.";
+
+/** The founders row's answer to an account not subscribed on a founding Price. */
+export const FOUNDERS_ONLY = "Only a founding subscription can join the founders row.";
/** Stripe's webhook endpoint. */
export const BILLING_WEBHOOK_PATH = "/api/billing/webhook";
@@ -78,12 +86,24 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
const summary = async (c: Context, billing: Billing, setup: BillingSetup, userId: string) => {
const [status, [account], founding] = await Promise.all([
billing.status(userId),
- accountQuery<{ entitled: boolean; suspended: boolean }>(
+ accountQuery<{
+ entitled: boolean;
+ suspended: boolean;
+ founder: boolean;
+ name: string | null;
+ consent: { termsVersion: string; consentedAt: string } | null;
+ }>(
host(c),
+ // A held subscription refuses another checkout, so the latest consent is its own.
`SELECT ${entitledSql("u")} AS entitled,
- EXISTS (SELECT FROM dormouse_billing_disputes d WHERE d."userId" = u.id) AS suspended
+ EXISTS (SELECT FROM dormouse_billing_disputes d WHERE d."userId" = u.id) AS suspended,
+ ${subscribedSql("u.id", "$2::text[]")} AS founder,
+ (SELECT f.name FROM dormouse_founders f WHERE f."userId" = u.id) AS name,
+ (SELECT json_build_object('termsVersion', k."termsVersion", 'consentedAt', k."consentedAt")
+ FROM dormouse_checkout_consents k WHERE k."userId" = u.id
+ ORDER BY k."consentedAt" DESC LIMIT 1) AS consent
FROM "user" u WHERE u.id = $1`,
- [userId],
+ [userId, setup.founding],
),
foundingCohort(billing, setup.founding),
]);
@@ -92,12 +112,16 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
const held = !!status.subscription && !ENDED.includes(status.subscription.status);
return c.json({
plan: held ? status.plan : null,
+ price: held ? publishedPrice(setup, status.subscription!.price_id) : null,
active: status.access,
until: held ? status.accessUntil : null,
renews: held && !status.subscription!.cancel_at_period_end,
entitled: account?.entitled === true,
suspended: account?.suspended === true,
founding,
+ // Offered while a founding subscription grants access; `name` while shown.
+ founder: { offered: account?.founder === true, name: account?.name ?? null },
+ consent: held ? (account?.consent ?? null) : null,
});
};
@@ -107,11 +131,13 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
app.post("/api/billing/checkout", limit(JSON_BODY_BYTES), gate, async (c) => {
const { userId, email } = c.get("login");
- const body = await readJson<{ plan?: unknown; ref?: unknown }>(c);
+ const body = await readJson<{ plan?: unknown; ref?: unknown; terms?: unknown; autoRenew?: unknown }>(c);
const plan = body?.plan;
if (!isPlan(plan)) return c.json({ message: "Choose monthly, yearly, or founding." }, 400);
const ref = checkoutRef(body?.ref);
return billed(c, async (billing) => {
+ if (typeof body?.terms !== "string" || body.autoRenew !== true) return c.json({ message: CONSENT_REQUIRED }, 400);
+ if (body.terms !== TERMS_VERSION) return c.json({ message: TERMS_CHANGED }, 409);
// A checkout left open for another plan gives way to this one.
const open = await billing.db
.selectFrom("checkouts")
@@ -121,7 +147,16 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
.executeTakeFirst();
if (open && open.plan !== plan) await billing.cancelCheckout(userId);
// Without an email, Stripe Checkout asks for one.
- const { url } = await billing.checkout(userId, email, plan, ref === "none" ? {} : { ref });
+ const { id, url } = await billing.checkout(userId, email, plan, ref === "none" ? {} : { ref });
+ // Recorded before the URL is answered, for the Price this checkout offers.
+ await billing.db.executeQuery(
+ CompiledQuery.raw(
+ `INSERT INTO public.dormouse_checkout_consents ("checkoutId", "termsVersion", "userId", plan, "priceId")
+ SELECT id, $2, owner_id, plan, price_id FROM pgstencil_billing.checkouts WHERE id = $1 AND owner_id = $3
+ ON CONFLICT DO NOTHING`,
+ [id, TERMS_VERSION, userId],
+ ),
+ );
recordMetric(c, host(c).databaseUrl, "checkout.started", `${plan}:${ref}`);
return c.json({ url });
});
@@ -142,6 +177,32 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
billed(c, async (billing) => c.json({ url: await billing.portal(c.get("login").userId) })),
);
+ // The founders row: shown under `name` only while a founding subscription
+ // grants access; leaving it is always allowed.
+ app.put("/api/billing/founder", limit(JSON_BODY_BYTES), gate, async (c) => {
+ const { userId } = c.get("login");
+ const body = await readJson<{ shown?: unknown; name?: unknown }>(c);
+ const shown = body?.shown;
+ const name = body?.name;
+ if (typeof shown !== "boolean" || (shown && !isFounderName(name)))
+ return c.json({ message: "Choose a name of 1 to 64 characters." }, 400);
+ return billed(c, async (billing, setup) => {
+ if (!shown) await accountQuery(host(c), `DELETE FROM dormouse_founders WHERE "userId" = $1`, [userId]);
+ else {
+ const joined = await accountQuery(
+ host(c),
+ `INSERT INTO dormouse_founders ("userId", name)
+ SELECT $1, $2 WHERE ${subscribedSql("$1::text", "$3::text[]")}
+ ON CONFLICT ("userId") DO UPDATE SET name = EXCLUDED.name
+ RETURNING "userId"`,
+ [userId, name, setup.founding],
+ );
+ if (!joined.length) return c.json({ message: FOUNDERS_ONLY }, 409);
+ }
+ return summary(c, billing, setup, userId);
+ });
+ });
+
// Stripe's: the raw body under its signature, 2xx only once `webhook()` has
// committed; any other failure throws to `onError`'s 503, which Stripe retries.
app.post(BILLING_WEBHOOK_PATH, limit(WEBHOOK_BODY_BYTES), async (c) => {
diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts
index e861c5c12..3c62cc87c 100644
--- a/hosted/server/billing.ts
+++ b/hosted/server/billing.ts
@@ -2,7 +2,7 @@
import { Billing, BillingError, Stripe, type BillingDB } from "@pgstencil/stripe";
import { SecureRandom, SystemTime } from "pgstencil";
import { connectDatabase, queryDatabase } from "pgstencil/postgres";
-import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER } from "../../website/src/lib/hosted-pricing";
+import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER, LIST_ANNUAL, LIST_MONTHLY } from "../../website/src/lib/hosted-pricing";
import type { BillingEnv } from "./bindings";
import { grantsAccessSql } from "./entitlement";
import { BILLING_PLANS, BILLING_RETURN_PATH } from "./policy-constants";
@@ -61,6 +61,17 @@ export function billingSetup(env: BillingEnv): BillingSetup | null {
};
}
+/**
+ * What `priceId` charges per interval, in whole US dollars, as the published
+ * prices say each configured Price does; null for a Price not configured.
+ */
+export function publishedPrice(setup: BillingSetup, priceId: string): number | null {
+ if (priceId === setup.monthly) return LIST_MONTHLY;
+ if (priceId === setup.yearly) return LIST_ANNUAL;
+ const step = setup.founding.indexOf(priceId);
+ return step === -1 ? null : FOUNDING_LADDER[step]!;
+}
+
/** The open cohort and its seats left; null once founding has closed. */
export interface OpenCohort {
/** Its index in `FOUNDING_LADDER`: how many cohorts have closed. */
diff --git a/hosted/server/dev.ts b/hosted/server/dev.ts
index a64b8f4a6..c90e6f899 100644
--- a/hosted/server/dev.ts
+++ b/hosted/server/dev.ts
@@ -8,7 +8,9 @@ import { Hono } from "hono";
import { createServer as createViteServer, type ViteDevServer } from "vite";
import { createAuthApp } from "@pgstencil/auth/better-auth";
import { developmentDatabase } from "pgstencil/database";
-import { EmailDev, SystemTime } from "pgstencil";
+import { EmailDev, SecureRandom, SystemTime } from "pgstencil";
+import { stripeDevBilling } from "./billing-dev";
+import { BILLING_WEBHOOK_PATH, billingRoutes, type BillingHost } from "./billing-routes";
import { authMetricHooks } from "./metrics";
import { authPolicy } from "./policy";
import { migrations } from "./migrations";
@@ -64,9 +66,18 @@ const auth = createAuthApp({
auth.app.get("/api/dev/emails", (c) =>
c.json(email.all().map(({ to, text }) => ({ to, text }))),
);
+// StripeDev in place of Stripe, so checkout runs end to end without a card or
+// a charge. Its state sits beside the development database's, which outlives
+// a restart, and its webhook comes back through this server.
+const stripe = await stripeDevBilling(
+ new SystemTime(),
+ new SecureRandom(),
+ fileURLToPath(new URL("../.pgstencil/stripe-dev.json", import.meta.url)),
+);
+stripe.dev.setWebhookTarget(origin + BILLING_WEBHOOK_PATH);
const app = new Hono();
// Built once, so the approval limiter counts across requests.
-const host: RelayAccountHost = {
+const host: RelayAccountHost & BillingHost = {
databaseUrl,
auth: (request: Request) => auth.app.fetch(request),
approveLimit: devLimit(10),
@@ -74,9 +85,11 @@ const host: RelayAccountHost = {
// removed Burrow's held socket closes only at its RelayRoom's next sweep
// (`RELAY_ROOM_SWEEP_MS`); its reconnect is refused at once.
closeBurrow: async () => {},
+ setup: () => stripe.setup,
};
voiceAccountRoutes(app, () => host);
relayAccountRoutes(app, () => host);
+billingRoutes(app, () => host);
// No speak: a Hosted build speaks only at the fixed voice origin, so no
// Dormouse build could reach one here.
app.all("*", (c) => auth.app.fetch(c.req.raw));
@@ -95,7 +108,7 @@ ready = {
vite,
};
console.log(
- `Dormouse Hosted: ${origin}\nLocal email inbox: ${origin}/api/dev/emails\nEmail stays local; OAuth is disabled in this development entry.`,
+ `Dormouse Hosted: ${origin}\nLocal email inbox: ${origin}/api/dev/emails\nEmail stays local; OAuth is disabled in this development entry.\nCheckout runs on StripeDev at ${stripe.dev.origin}: no card, no charge.`,
);
// OSC 367 (docs/specs/dor-tool.md -> OSC 367): frame this origin, not the
// relay's port or Wrangler's inspector, which the same process tree binds.
@@ -154,6 +167,7 @@ for (const signal of ["SIGINT", "SIGTERM"] as const)
await vite.close();
await auth.close();
email.close();
+ await stripe.dev.close();
await relayExit;
process.exit(0);
});
diff --git a/hosted/server/dormouse-migrations/015_founders.sql b/hosted/server/dormouse-migrations/015_founders.sql
new file mode 100644
index 000000000..0327d8651
--- /dev/null
+++ b/hosted/server/dormouse-migrations/015_founders.sql
@@ -0,0 +1,18 @@
+-- Up Migration
+-- A founding member who chose to appear in the founders row, under the name
+-- they chose (docs/specs/hosted.md -> "Billing"). No row is the default: the
+-- opt-in starts unticked, and unticking deletes the row. The CHECK refuses a
+-- name with a Cc, Cf, Zl or Zp character, padding, nothing, or more than 64
+-- code points, as 012's label CHECK does.
+CREATE TABLE dormouse_founders (
+ "userId" text PRIMARY KEY REFERENCES "user" (id) ON DELETE CASCADE,
+ name text NOT NULL CHECK (
+ char_length(name) BETWEEN 1 AND 64
+ AND name = btrim(name)
+ AND name !~ '[\u0001-\u001f\u007f-\u009f-- ----\U000110bd\U000110cd\U00013430-\U0001343f\U0001bca0-\U0001bca3\U0001d173-\U0001d17a\U000e0001\U000e0020-\U000e007f]'
+ ),
+ "shownSince" timestamptz NOT NULL DEFAULT now()
+);
+
+-- Down Migration
+DROP TABLE dormouse_founders;
diff --git a/hosted/server/dormouse-migrations/016_checkout_consents.sql b/hosted/server/dormouse-migrations/016_checkout_consents.sql
new file mode 100644
index 000000000..64a911a60
--- /dev/null
+++ b/hosted/server/dormouse-migrations/016_checkout_consents.sql
@@ -0,0 +1,20 @@
+-- Up Migration
+-- The consent an account gave when it started a checkout (docs/specs/hosted.md
+-- -> "Billing"): a row exists only for a checkout started after agreeing to
+-- the terms version it names and to automatic renewal, for the plan and Price
+-- that checkout offered. One row per checkout and terms version, never edited.
+-- No foreign key: like pgstencil_billing's rows, it is a billing record kept
+-- after the account is deleted.
+CREATE TABLE dormouse_checkout_consents (
+ "checkoutId" text NOT NULL,
+ "termsVersion" text NOT NULL,
+ "userId" text NOT NULL,
+ plan text NOT NULL,
+ "priceId" text NOT NULL,
+ "consentedAt" timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY ("checkoutId", "termsVersion")
+);
+CREATE INDEX dormouse_checkout_consents_user ON dormouse_checkout_consents ("userId");
+
+-- Down Migration
+DROP TABLE dormouse_checkout_consents;
diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts
index 352e1885c..0854580a0 100644
--- a/hosted/server/entitlement.ts
+++ b/hosted/server/entitlement.ts
@@ -25,12 +25,14 @@ export const grantsAccessSql = (s: string) =>
/**
* Whether the SQL expression `owner` holds exactly one current subscription
- * and it grants access, as `status()` decides.
+ * and it grants access, as `status()` decides; with `prices`, an SQL `text[]`
+ * expression, only while that subscription is on one of them.
*/
-function subscribedSql(owner: string): string {
+export function subscribedSql(owner: string, prices?: string): string {
+ const on = prices ? ` AND entitling.price_id = ANY (${prices})` : "";
// An alias no caller's query uses, so `owner` can name the outer row.
return `coalesce((SELECT count(*) FILTER (WHERE entitling.status IN (${CURRENT})) = 1
- AND bool_or(${grantsAccessSql("entitling")})
+ AND bool_or(${grantsAccessSql("entitling")}${on})
FROM pgstencil_billing.subscriptions entitling WHERE entitling.owner_id = ${owner}), false)`;
}
diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts
index 4cdcb4ff6..6c1786952 100644
--- a/hosted/server/policy-constants.ts
+++ b/hosted/server/policy-constants.ts
@@ -38,6 +38,22 @@ export const TERMS_VERSION = "2026-10-28";
// account page on the account origin.
export const BILLING_RETURN_PATH = "/billing";
+// Speaks one account may make per UTC day (the voice Worker's cap), which
+// checkout discloses before purchase.
+export const VOICE_DAILY_CAP = 500;
+
+// Every billing route's answer while billing is closed, which the account page
+// reads as closed rather than as an outage.
+export const CHECKOUT_CLOSED = "Checkout is not open yet.";
+
// The plans checkout sells, by the names a checkout request, `status()`, and
// the billing metrics use.
export const BILLING_PLANS = ["monthly", "yearly", "founding"] as const;
+
+// A name the founders row shows: 1–64 code points, none a control, format, or
+// line or paragraph separator, without padding. The account page checks it
+// before sending; the founder route and dormouse_founders' CHECK refuse
+// anything else.
+const FOUNDER_NAME = /^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]{1,64}$/u;
+export const isFounderName = (name: unknown): name is string =>
+ typeof name === "string" && FOUNDER_NAME.test(name) && name === name.trim();
diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts
index 100d6c87c..b2213aea2 100644
--- a/hosted/server/tests/billing-routes.test.ts
+++ b/hosted/server/tests/billing-routes.test.ts
@@ -3,18 +3,22 @@ import { randomUUID } from "node:crypto";
import { Hono } from "hono";
import { createTestContext } from "pgstencil/testing";
import { queryDatabase } from "pgstencil/postgres";
-import { FOUNDING_COHORT_SIZE } from "../../../website/src/lib/hosted-pricing";
+import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER, LIST_ANNUAL } from "../../../website/src/lib/hosted-pricing";
import type { BillingSetup } from "../billing";
import {
BILLING_WEBHOOK_PATH,
CHECKOUT_CLOSED,
+ CONSENT_REQUIRED,
ENDED_FOR_REFUND,
+ FOUNDERS_ONLY,
+ TERMS_CHANGED,
WEBHOOK_BODY_BYTES,
billingRoutes,
disputeAction,
} from "../billing-routes";
import { entitledSql } from "../entitlement";
import { migrations } from "../migrations";
+import { TERMS_VERSION } from "../policy-constants";
import { ORIGINS } from "./bundle";
import { stripeFake } from "./stripe-fake";
import { workerDatabases } from "./worker-roles";
@@ -23,6 +27,8 @@ import { workerDatabases } from "./worker-roles";
// signed-in account may ask, what Stripe's webhook does, and what each counts.
const origin = ORIGINS.account;
+/** What the checkout page sends once both boxes are ticked. */
+const CONSENT = { terms: TERMS_VERSION, autoRenew: true };
async function fixture({ closed = false } = {}) {
const context = await createTestContext({ migrations, now: new Date().toISOString() });
@@ -95,11 +101,11 @@ async function fixture({ closed = false } = {}) {
app.request(origin + path, {
method,
headers: { "content-type": "application/json", ...(method === "GET" ? { "sec-fetch-site": "same-origin" } : { origin }) },
- ...(method === "POST" && { body: "{}" }),
+ ...(method !== "GET" && { body: "{}" }),
});
/** Starts `plan` for `who` and completes it in StripeDev, delivering its events. */
const buy = async (who: Awaited>, plan: string, ref?: string) => {
- const started = await who.request("/api/billing/checkout", { method: "POST", body: { plan, ref } });
+ const started = await who.request("/api/billing/checkout", { method: "POST", body: { plan, ref, ...CONSENT } });
expect(started.status, await started.clone().text()).toBe(200);
const { url } = (await started.json()) as { url: string };
const session = [...stripe.dev.checkouts.values()].find((s) => s.url === url)!;
@@ -158,6 +164,7 @@ test("closed, every billing route answers 503 to a signed-in account, after the
["POST", "/api/billing/checkout", { plan: "monthly" }],
["POST", "/api/billing/confirm", { checkout: "op_x" }],
["POST", "/api/billing/portal"],
+ ["PUT", "/api/billing/founder", { shown: false }],
] as const) {
const response = await member.request(path, { method, body });
expect([path, response.status, await response.json()]).toEqual([path, 503, { message: CHECKOUT_CLOSED }]);
@@ -192,6 +199,7 @@ test("checkout takes a plan and a ref, never a Price, customer, quantity, or ret
body: {
plan: "monthly",
ref: "settings-voice",
+ ...CONSENT,
price: "price_x",
customer: "cus_x",
quantity: 3,
@@ -226,12 +234,15 @@ test("checkout takes a plan and a ref, never a Price, customer, quantity, or ret
expect(await member.summary()).toEqual({
plan: "founding",
+ price: FOUNDING_LADDER[0],
active: true,
until: expect.any(String),
renews: true,
entitled: true,
suspended: false,
founding: { cohort: 0, seatsLeft: FOUNDING_COHORT_SIZE - 1 },
+ founder: { offered: true, name: null },
+ consent: { termsVersion: TERMS_VERSION, consentedAt: expect.any(String) },
});
// The return page confirms the operation it names, and only a well-formed one.
for (const checkout of ["", "../x", "x".repeat(65), 7])
@@ -244,7 +255,7 @@ test("checkout takes a plan and a ref, never a Price, customer, quantity, or ret
// A held subscription is managed in the portal, never bought twice.
const portal = await member.request("/api/billing/portal", { method: "POST" });
expect(((await portal.json()) as { url: string }).url).toBe(`${f.dev.origin}/portal/${session.customer}`);
- expect((await member.request("/api/billing/checkout", { method: "POST", body: { plan: "yearly" } })).status).toBe(409);
+ expect((await member.request("/api/billing/checkout", { method: "POST", body: { plan: "yearly", ...CONSENT } })).status).toBe(409);
// An unknown ref counts as `other`, and none as `none`, which sends Stripe no ref.
const other = await f.account();
@@ -259,7 +270,7 @@ test("checkout takes a plan and a ref, never a Price, customer, quantity, or ret
});
// Nor does an address the account has not verified.
const unverified = await f.account(`${randomUUID()}@example.test`, false);
- expect((await unverified.request("/api/billing/checkout", { method: "POST", body: { plan: "monthly" } })).status).toBe(200);
+ expect((await unverified.request("/api/billing/checkout", { method: "POST", body: { plan: "monthly", ...CONSENT } })).status).toBe(200);
expect(f.dev.requests.filter((r) => r.path === "/v1/customers").at(-1)!.body).toEqual({
"metadata[pgstencil_owner]": unverified.id,
});
@@ -276,6 +287,122 @@ test("checkout takes a plan and a ref, never a Price, customer, quantity, or ret
);
});
+test("checkout starts only after agreement to the current terms and to automatic renewal, recorded with what it offered", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const member = await f.account();
+ const consents = () =>
+ f.sql<{ checkoutId: string; termsVersion: string; userId: string; plan: string; priceId: string }>(
+ `SELECT "checkoutId", "termsVersion", "userId", plan, "priceId" FROM dormouse_checkout_consents ORDER BY "consentedAt"`,
+ );
+ for (const consent of [{}, { terms: TERMS_VERSION }, { terms: TERMS_VERSION, autoRenew: "yes" }, { autoRenew: true }]) {
+ const refused = await member.request("/api/billing/checkout", { method: "POST", body: { plan: "yearly", ...consent } });
+ expect([refused.status, await refused.json()], JSON.stringify(consent)).toEqual([400, { message: CONSENT_REQUIRED }]);
+ }
+ const stale = await member.request("/api/billing/checkout", {
+ method: "POST",
+ body: { plan: "yearly", terms: "2020-01-01", autoRenew: true },
+ });
+ expect([stale.status, await stale.json()]).toEqual([409, { message: TERMS_CHANGED }]);
+ expect(f.dev.requests.filter((r) => r.path === "/v1/checkout/sessions")).toEqual([]);
+ expect(await consents()).toEqual([]);
+
+ // Recorded as the checkout starts; the plan's confirmation shows it only once one completes.
+ expect((await member.request("/api/billing/checkout", { method: "POST", body: { plan: "monthly", ...CONSENT } })).status).toBe(200);
+ expect(await consents()).toMatchObject([{ plan: "monthly", priceId: f.setup.monthly }]);
+ expect(await member.summary()).toMatchObject({ plan: null, consent: null });
+ const { session } = await f.buy(member, "yearly");
+ const operation = session.metadata!.pgstencil_operation!;
+ expect((await consents())[1]).toEqual({
+ checkoutId: operation,
+ termsVersion: TERMS_VERSION,
+ userId: member.id,
+ plan: "yearly",
+ priceId: f.setup.yearly,
+ });
+ const [{ at }] = await f.sql<{ at: Date }>(`SELECT "consentedAt" AS at FROM dormouse_checkout_consents WHERE "checkoutId" = $1`, [
+ operation,
+ ]);
+ const held = await member.summary();
+ expect(held).toMatchObject({
+ plan: "yearly",
+ price: LIST_ANNUAL,
+ founder: { offered: false, name: null },
+ consent: { termsVersion: TERMS_VERSION },
+ });
+ expect(Date.parse((held.consent as { consentedAt: string }).consentedAt)).toBe(at.getTime());
+ // Kept once the subscription and the account are gone: it is a billing record.
+ await f.sql(`DELETE FROM "user" WHERE id = $1`, [member.id]);
+ expect(await consents()).toHaveLength(2);
+});
+
+test("only a founding subscription joins the founders row, under a checked name, and leaving is always allowed", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const founders = () => f.sql<{ userId: string; name: string }>(`SELECT "userId", name FROM dormouse_founders`);
+ const put = (who: Awaited>, body: unknown) =>
+ who.request("/api/billing/founder", { method: "PUT", body });
+
+ const yearly = await f.account();
+ await f.buy(yearly, "yearly");
+ const refused = await put(yearly, { shown: true, name: "Yearly" });
+ expect([refused.status, await refused.json()]).toEqual([409, { message: FOUNDERS_ONLY }]);
+ expect((await put(await f.account(), { shown: true, name: "Nobody" })).status).toBe(409);
+
+ const founder = await f.account();
+ const { subscription } = await f.buy(founder, "founding");
+ for (const body of [
+ { shown: true },
+ { shown: true, name: "" },
+ { shown: true, name: " Ada" },
+ { shown: true, name: "Ada\u0007" },
+ { shown: true, name: "A\u200bda" },
+ { shown: true, name: "Ada\u2028Lovelace" },
+ { shown: true, name: "x".repeat(65) },
+ { shown: "yes", name: "Ada" },
+ { name: "Ada" },
+ ])
+ expect((await put(founder, body)).status, JSON.stringify(body)).toBe(400);
+ // Another origin's page never changes it.
+ expect(
+ (await founder.request("/api/billing/founder", { method: "PUT", body: { shown: false }, headers: { origin: ORIGINS.relay } }))
+ .status,
+ ).toBe(403);
+ expect(await founders()).toEqual([]);
+ // The table refuses what the route does, should a later writer skip the check.
+ for (const name of [" Ada", "A\u200bda", "Ada\u0085", "x".repeat(65)])
+ await expect(
+ f.sql(`INSERT INTO dormouse_founders ("userId", name) VALUES ($1, $2)`, [founder.id, name]),
+ JSON.stringify(name),
+ ).rejects.toThrow(/check constraint/);
+
+ const shown = await put(founder, { shown: true, name: "🐭".repeat(64) });
+ expect([shown.status, await shown.json()]).toEqual([
+ 200,
+ expect.objectContaining({ founder: { offered: true, name: "🐭".repeat(64) } }),
+ ]);
+ const [{ since }] = await f.sql<{ since: Date }>(`SELECT "shownSince" AS since FROM dormouse_founders`);
+ // A rename keeps its place in the row.
+ expect((await put(founder, { shown: true, name: "Ada Lovelace" })).status).toBe(200);
+ expect(await f.sql(`SELECT name, "shownSince" AS since FROM dormouse_founders`)).toEqual([{ name: "Ada Lovelace", since }]);
+ expect((await put(founder, { shown: false })).status).toBe(200);
+ expect(await founders()).toEqual([]);
+ expect((await put(founder, { shown: true, name: "Ada" })).status).toBe(200);
+
+ // Lapsed, it cannot join or rename, but it can still leave.
+ f.dev.transition(subscription.id, "cancel");
+ await f.deliver();
+ expect((await put(founder, { shown: true, name: "Ada again" })).status).toBe(409);
+ expect(await founders()).toEqual([{ userId: founder.id, name: "Ada" }]);
+ expect(await founder.summary()).toMatchObject({ founder: { offered: false, name: "Ada" } });
+ expect((await put(founder, { shown: false })).status).toBe(200);
+ expect(await founders()).toEqual([]);
+});
+
test("the webhook verifies the raw body before any write, caps it, and refuses another mode, API version, or Connect account", async ({
onTestFinished,
}) => {
diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts
index ef51afc6c..fb9515db8 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -41,6 +41,10 @@ const PINNED: Record = {
"ffb460491d0d9d3cf00b33067b6cbe8dae04ca68fc663cb005aabf15619a08eb",
"014_billing_disputes.sql":
"ddf7454ce96f389f153d76d4001ca559fb5bd8969e6fda46e68dcd10e901a502",
+ "015_founders.sql":
+ "1610ccafe5450a7960a00cf7e393c6d99c22b20170ef230c76544632e6806eeb",
+ "016_checkout_consents.sql":
+ "0d0fcf0a44651ca819ee279d046ef55a622fa6398cf99e3b3d69b9a045ffe74c",
};
const directory = new URL("../dormouse-migrations/", import.meta.url);
diff --git a/hosted/server/voice.ts b/hosted/server/voice.ts
index 1e0417d5d..e7d54e949 100644
--- a/hosted/server/voice.ts
+++ b/hosted/server/voice.ts
@@ -6,8 +6,9 @@ import { isManagedVoiceId, isManagedVoiceToken, parseBearer } from "remote-lib-c
import { accountQuery, cookieLogin, type AccountHost } from "./account-gate";
import { entitledSql } from "./entitlement";
import { recordMetric } from "./metrics";
+import { VOICE_DAILY_CAP } from "./policy-constants";
-export const VOICE_DAILY_CAP = 500;
+export { VOICE_DAILY_CAP };
/** A token unused this long is refused, as if it never existed. */
export const VOICE_TOKEN_IDLE_DAYS = 365;
/** Whether the token aliased `t` is live: used, or minted, within the idle bound. */
From c4ae9a891e878cdc5d0e61c4cfd41fb75fd96762 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 05:18:43 -0700
Subject: [PATCH 2/6] Hosted: checkout, billing, and plan pages in the account
app
/checkout signs in, then offers the plans on sale at today's price
(founding at the open cohort's step) with what each commits the buyer
to beside the purchase action, and starts Stripe Checkout only once the
buyer ticks agreement to the current terms version and to automatic
renewal. The plan and ref survive a provider's sign-in in sessionStorage
for ten minutes. /billing confirms the checkout Stripe returned from,
strips the query, and keeps a durable confirmation with the founders
opt-in, unticked. The account page gains a Plan section with Manage
billing, whose lapsed copy branches on entitled. With billing open, the
no-plan offer links to checkout.
Co-Authored-By: Claude Opus 5.5
---
hosted/src/App.test.tsx | 168 ++++++++++++++++++++++++++++-
hosted/src/App.tsx | 90 ++++++++++++++--
hosted/src/Checkout.tsx | 157 +++++++++++++++++++++++++++
hosted/src/HostedPage.tsx | 117 ++++++++++++++++++--
hosted/src/NoPlanOffer.tsx | 13 ++-
hosted/src/Plan.tsx | 215 +++++++++++++++++++++++++++++++++++++
hosted/src/api.ts | 73 ++++++++++++-
hosted/src/billing.test.ts | 69 ++++++++++++
hosted/src/billing.ts | 143 ++++++++++++++++++++++++
hosted/src/classes.ts | 5 +
hosted/src/main.tsx | 5 +-
11 files changed, 1033 insertions(+), 22 deletions(-)
create mode 100644 hosted/src/Checkout.tsx
create mode 100644 hosted/src/Plan.tsx
create mode 100644 hosted/src/billing.test.ts
create mode 100644 hosted/src/billing.ts
diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx
index a59da41e8..6082addd3 100644
--- a/hosted/src/App.test.tsx
+++ b/hosted/src/App.test.tsx
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import * as api from "./api";
import type { Enrollment } from "./enrollment";
+import type { BillingSummary, CheckoutIntent } from "./billing";
import { ENROLLMENT_POLL_INTERVAL_S, TERMS_VERSION } from "../server/policy-constants";
import { NOT_ENTITLED_ERROR } from "../../remote-lib-common/src/remote/wire.ts";
@@ -15,6 +16,8 @@ vi.mock("./api", async (original) => ({
getHostedVoice: vi.fn(), getComputers: vi.fn(), getPasskeys: vi.fn(),
getApproval: vi.fn(), approveEnrollment: vi.fn(), acceptTerms: vi.fn(),
post: vi.fn(), turnOffHostedVoice: vi.fn(),
+ getBilling: vi.fn(), startCheckout: vi.fn(), confirmCheckout: vi.fn(),
+ openPortal: vi.fn(), setFounder: vi.fn(),
}));
const NOW = Date.UTC(2026, 9, 21, 12);
@@ -50,6 +53,7 @@ beforeEach(() => {
vi.mocked(api.getApproval).mockResolvedValue({ mismatched: false, redeemed: false });
vi.mocked(api.acceptTerms).mockResolvedValue(true);
vi.mocked(api.post).mockResolvedValue({});
+ vi.mocked(api.getBilling).mockResolvedValue("closed");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@@ -61,8 +65,8 @@ afterEach(async () => {
vi.useRealTimers();
});
-async function mount(enrollment: Enrollment | null) {
- await act(async () => root.render());
+async function mount(enrollment: Enrollment | null, start: { checkout?: CheckoutIntent; confirming?: string } = {}) {
+ await act(async () => root.render());
}
function button(text: string) {
const found = [...container.querySelectorAll("button")].find((el) => el.textContent?.trim() === text);
@@ -100,6 +104,9 @@ async function signIn() {
});
expect(api.acceptTerms).toHaveBeenCalledWith(TERMS_VERSION);
}
+async function tick(id: string) {
+ await act(async () => container.querySelector(`#${id}`)!.click());
+}
async function approve() {
await input("confirm-digits", "47");
await submit();
@@ -315,3 +322,160 @@ describe("approveEnrollment", () => {
await expect(refusal).rejects.not.toBeInstanceOf(NoPlanError);
});
});
+
+const NO_PLAN: BillingSummary = {
+ plan: null, price: null, active: false, until: null, renews: false, entitled: false, suspended: false,
+ founding: { cohort: 0, seatsLeft: 42 }, founder: { offered: false, name: null }, consent: null,
+};
+const FOUNDING: BillingSummary = {
+ ...NO_PLAN, plan: "founding", price: 50, active: true, until: "2027-10-21T12:00:00Z", renews: true, entitled: true,
+ founder: { offered: true, name: null },
+ consent: { termsVersion: TERMS_VERSION, consentedAt: "2026-10-21T12:00:00Z" },
+};
+const text = () => container.textContent!;
+
+describe("checkout", () => {
+ const heading = () => container.querySelector("h1")?.textContent;
+
+ it("asks a signed-out buyer to sign in, without the not-yet footnote, and keeps the plan", async () => {
+ history.replaceState(null, "", "/checkout");
+ vi.mocked(api.getBilling).mockResolvedValue(NO_PLAN);
+ await mount(null, { checkout: { plan: "yearly", ref: "settings-voice" } });
+ expect(heading()).toBe("Sign in to subscribe");
+ // A reload signed out still lands on checkout.
+ expect(location.pathname).toBe("/checkout");
+ expect(text()).not.toContain("not available yet");
+ expect(container.querySelector("#email")).not.toBeNull();
+ await signIn();
+ expect(location.pathname).toBe("/checkout");
+ expect(heading()).toBe("Subscribe to Dormouse Hosted");
+ expect(container.querySelector('input[value="yearly"]')!.checked).toBe(true);
+ });
+
+ it("starts only once both agreements are ticked, with the plan and the ref it arrived with", async () => {
+ vi.mocked(api.getSession).mockResolvedValue(login());
+ vi.mocked(api.getBilling).mockResolvedValue(NO_PLAN);
+ await mount(null, { checkout: { plan: null, ref: "hosted-no-plan" } });
+ // Founding first while it is open.
+ expect(container.querySelector('input[value="founding"]')!.checked).toBe(true);
+ expect(text()).toContain("42 left at this price");
+ const go = () => button("Continue to payment");
+ expect(go().disabled).toBe(true);
+ await tick("agree-terms");
+ expect(go().disabled).toBe(true);
+ await tick("agree-renewal");
+ expect(go().disabled).toBe(false);
+ // Renewal was agreed at founding's price: another plan asks again.
+ await tick("monthly-plan");
+ expect(container.querySelector("#agree-renewal")!.checked).toBe(false);
+ expect(text()).toContain("renews automatically every month at $10");
+ expect(go().disabled).toBe(true);
+ await tick("agree-renewal");
+ await click("Continue to payment");
+ expect(api.startCheckout).toHaveBeenCalledWith("monthly", "hosted-no-plan");
+ });
+
+ it("says when checkout is not open, and offers a held plan the portal instead", async () => {
+ vi.mocked(api.getSession).mockResolvedValue(login());
+ await mount(null, { checkout: { plan: "founding", ref: null } });
+ expect(text()).toContain("Checkout is not open yet.");
+ expect(container.querySelector("#agree-terms")).toBeNull();
+ vi.mocked(api.getBilling).mockResolvedValue(FOUNDING);
+ await act(async () => window.dispatchEvent(new Event("focus")));
+ expect(text()).toContain("This account already has Founding.");
+ await click("Manage billing");
+ expect(api.openPortal).toHaveBeenCalled();
+ await click("Go to your account");
+ expect(location.pathname).toBe("/account");
+ expect(heading()).toBe("Your account");
+ });
+});
+
+describe("billing", () => {
+ it("confirms the checkout Stripe returned from once, then keeps its confirmation", async () => {
+ history.replaceState(null, "", "/billing");
+ vi.mocked(api.getSession).mockResolvedValue(login());
+ vi.mocked(api.confirmCheckout).mockResolvedValue(FOUNDING);
+ await mount(null, { confirming: "op_1" });
+ expect(api.confirmCheckout).toHaveBeenCalledWith("op_1");
+ expect(api.getBilling).not.toHaveBeenCalled();
+ expect(location.pathname).toBe("/billing");
+ expect(container.querySelector("h1")?.textContent).toBe("You’re subscribed");
+ expect(text()).toContain(`To the Terms (version ${TERMS_VERSION}) and to automatic renewal`);
+ expect(text()).toContain("at $50 plus any applicable tax");
+ vi.mocked(api.getBilling).mockResolvedValue(FOUNDING);
+ await act(async () => window.dispatchEvent(new Event("focus")));
+ expect(api.confirmCheckout).toHaveBeenCalledTimes(1);
+ });
+
+ it("offers a founder the row unticked, and saves the name they choose", async () => {
+ history.replaceState(null, "", "/billing");
+ vi.mocked(api.getSession).mockResolvedValue(login());
+ vi.mocked(api.getBilling).mockResolvedValue(FOUNDING);
+ vi.mocked(api.setFounder).mockResolvedValue({ ...FOUNDING, founder: { offered: true, name: "Ada" } });
+ await mount(null);
+ expect(container.querySelector("#founder-shown")!.checked).toBe(false);
+ expect(container.querySelector("#founder-name")).toBeNull();
+ await tick("founder-shown");
+ expect(container.querySelector("#founder-name")!.value).toBe("Member");
+ await input("founder-name", " ");
+ expect(button("Save").disabled).toBe(true);
+ await input("founder-name", " Ada ");
+ await click("Save");
+ expect(api.setFounder).toHaveBeenCalledWith(true, "Ada");
+ expect(text()).toContain("You are in the founders row as Ada.");
+ });
+});
+
+describe("the plan section", () => {
+ beforeEach(() => {
+ vi.mocked(api.getSession).mockResolvedValue(login());
+ });
+
+ it("is absent while checkout is closed", async () => {
+ await mount(null);
+ expect(container.querySelector("#plan")).toBeNull();
+ });
+
+ it.each([
+ [false, "so Natural Voice and the Hosted Relay are paused"],
+ [true, "Your account keeps Hosted at no charge"],
+ ])("keeps Manage billing for a payment that failed, entitled %s", async (entitled, copy) => {
+ vi.mocked(api.getBilling).mockResolvedValue({ ...FOUNDING, active: false, entitled, founder: { offered: false, name: null } });
+ await mount(null);
+ expect(container.querySelector("#plan")).not.toBeNull();
+ expect(text()).toContain(copy);
+ await click("Manage billing");
+ expect(api.openPortal).toHaveBeenCalled();
+ });
+
+ it("lets a lapsed founder leave the row", async () => {
+ vi.mocked(api.getBilling).mockResolvedValue({ ...NO_PLAN, founder: { offered: false, name: "Ada" } });
+ vi.mocked(api.setFounder).mockResolvedValue(NO_PLAN);
+ await mount(null);
+ expect(container.querySelector('a[href="/checkout"]')).not.toBeNull();
+ await click("Leave the founders row");
+ expect(api.setFounder).toHaveBeenCalledWith(false, "");
+ });
+
+ it("sends an account with no plan, enrolling, to checkout in another tab once it is open", async () => {
+ vi.mocked(api.getHostedVoice).mockResolvedValue({ entitled: false, on: false });
+ vi.mocked(api.getBilling).mockResolvedValue(NO_PLAN);
+ await mount(ENROLLMENT);
+ const plans = container.querySelector('a[href="/checkout?ref=hosted-no-plan"]')!;
+ expect(plans.target).toBe("_blank");
+ });
+});
+
+describe("startCheckout", () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it("agrees to the current terms and to renewal, and leaves only for an https page", async () => {
+ const { startCheckout } = await vi.importActual("./api");
+ const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ url: "javascript:alert(1)" }));
+ await expect(startCheckout("yearly", "settings-voice")).rejects.toThrow("Billing is temporarily unavailable");
+ expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toEqual({
+ plan: "yearly", ref: "settings-voice", terms: TERMS_VERSION, autoRenew: true,
+ });
+ });
+});
diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx
index 5a4b40db8..333cef6c4 100644
--- a/hosted/src/App.tsx
+++ b/hosted/src/App.tsx
@@ -8,7 +8,9 @@ import {
import {
acceptTerms,
approveEnrollment,
+ confirmCheckout,
getAccounts,
+ getBilling,
getApproval,
getComputers,
getHostedVoice,
@@ -16,10 +18,13 @@ import {
getProviders,
getSession,
NoPlanError,
+ openPortal,
post,
removeComputer,
removePasskey,
+ setFounder,
social,
+ startCheckout,
turnOffHostedVoice,
type Account,
type Computer,
@@ -34,7 +39,8 @@ import {
TERMS_VERSION,
} from "../server/policy-constants";
import { useDocsThemedBody } from "dormouse-lib/site/docs-paint";
-import { HostedPage, SUBSCRIBE_URL } from "./HostedPage";
+import { HostedPage, SUBSCRIBE_URL, type View } from "./HostedPage";
+import { forgetCheckout, type BillingSummary, type CheckoutIntent, type Plan } from "./billing";
import { takeEnrollment, type Enrollment } from "./enrollment";
import {
afterRead,
@@ -85,11 +91,24 @@ const SUBSCRIBE_REDIRECT_MS = 4000;
const SIGN_IN_PARAMS = new URLSearchParams(location.search);
const SIGN_IN_OPEN = SIGN_IN_PARAMS.has("signin") || SIGN_IN_PARAMS.has("error");
+/** The address bar's path for each page past enrollment. */
+const VIEW_PATHS: Record = { account: "/account", checkout: "/checkout", billing: "/billing" };
+
// How long the page re-reads for a Pending before saying it has not shown;
// after that the focus refresh picks it up.
const PENDING_TIMEOUT_MS = 60_000;
-export function App({ enrollment }: { enrollment: Enrollment | null }) {
+export function App({
+ enrollment,
+ checkout = null,
+ confirming = null,
+}: {
+ enrollment: Enrollment | null;
+ /** The checkout this load continues (`takeCheckout`). */
+ checkout?: CheckoutIntent | null;
+ /** The checkout Stripe returned from to `/billing` (`takeConfirmation`). */
+ confirming?: string | null;
+}) {
// The reader's picked theme, as on the docs pages this continues.
useDocsThemedBody();
const [session, setSession] = useState(null);
@@ -102,6 +121,19 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
const [computers, setComputers] = useState(null);
// Null hides the Passkeys section.
const [passkeys, setPasskeys] = useState(null);
+ // Null hides the Plan section; "closed" until checkout opens.
+ const [billing, setBilling] = useState(null);
+ const [view, setViewState] = useState(
+ checkout ? "checkout" : location.pathname === "/billing" ? "billing" : "account",
+ );
+ const viewRef = useRef(view);
+ const setView = (next: View) => {
+ viewRef.current = next;
+ setViewState(next);
+ };
+ // Confirmed once, on the first read signed in.
+ const confirmingRef = useRef(confirming);
+ const [confirmed, setConfirmed] = useState(false);
// The enrollment awaiting approval, in memory only: through sign-in and
// back, never into storage (docs/specs/hosted.md -> "Burrow enrollment").
const [enrolling, setEnrollingState] = useState(enrollment);
@@ -145,7 +177,22 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
).catch(failed);
// A failed list hides its section, never the account page.
const watched = awaited.current?.pending.userCode;
- const [linked, voice, enrolled, registered, approval] = current
+ let confirmFailed: unknown = null;
+ /** The account's plan, confirming first the checkout Stripe returned from. */
+ const readBilling = async () => {
+ const checkout = confirmingRef.current;
+ confirmingRef.current = null;
+ if (checkout)
+ try {
+ const confirmedPlan = await confirmCheckout(checkout);
+ setConfirmed(true);
+ return confirmedPlan;
+ } catch (error) {
+ confirmFailed = error;
+ }
+ return getBilling().catch(() => null);
+ };
+ const [linked, voice, enrolled, registered, approval, plan] = current
? await Promise.all([
asRead(getAccounts()).catch(failed),
getHostedVoice().catch(() => null),
@@ -154,11 +201,12 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
watched === undefined
? undefined
: getApproval(watched).catch(() => undefined),
+ readBilling(),
])
- : [[], null, null, null, undefined];
+ : [[], null, null, null, undefined, null];
if (current) await recordContinued().catch(() => {});
if (generation !== refreshGeneration.current) return;
- setError(afterRead);
+ setError(confirmFailed ? pageError(confirmFailed) : afterRead);
setLookup(current ? "signed-in" : "signed-out");
setSession(current);
setEnabled(providers);
@@ -168,10 +216,15 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
if (voice?.entitled) setRefusedPlan(false);
setComputers(enrolled);
setPasskeys(registered);
+ setBilling(plan);
history.replaceState(
null,
"",
- enrollingRef.current ? "/enroll" : current ? "/account" : "/login",
+ enrollingRef.current
+ ? "/enroll"
+ : current || viewRef.current !== "account"
+ ? VIEW_PATHS[viewRef.current]
+ : "/login",
);
const state: AccountState = { voice, computers: enrolled, approval };
const late = awaited.current?.late && pendingEnd(awaited.current.pending, state);
@@ -216,7 +269,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
// A failed lookup keeps its error and Retry on screen rather than sending
// a member who is signed in away during an outage, even once that error is
// cleared (a new enrollment link, Don't approve).
- const comingSoon = preLaunchNotice(lookup, SIGN_IN_OPEN);
+ // Checkout asks for sign-in, so it never shows the notice.
+ const comingSoon = preLaunchNotice(lookup, SIGN_IN_OPEN || view !== "account");
useEffect(() => {
if (!comingSoon) return;
const timer = setTimeout(
@@ -327,6 +381,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
setHostedVoice(null);
setComputers(null);
setPasskeys(null);
+ setBilling(null);
setRefusedPlan(false);
setEnterCode(false);
setCode("");
@@ -385,6 +440,25 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
setError(keepRead);
history.replaceState(null, "", session ? "/account" : "/login");
};
+ const purchase = (plan: Plan) =>
+ act("checkout", async () => {
+ await startCheckout(plan, checkout?.ref ?? null);
+ // This tab is on its way to Stripe: nothing to come back to.
+ forgetCheckout();
+ });
+ const manageBilling = () => act("portal", openPortal);
+ const saveFounder = (shown: boolean, name: string) =>
+ act("founder", async () => {
+ setBilling(await setFounder(shown, name));
+ setNotice({ text: shown ? `You are in the founders row as ${name}.` : "You left the founders row." });
+ });
+ const leaveBilling = () => {
+ forgetCheckout();
+ setView("account");
+ setError(keepRead);
+ setNotice(null);
+ history.replaceState(null, "", session ? "/account" : "/login");
+ };
const removeOne = (burrowId: string) =>
mutate(`remove-${burrowId}`, () => removeComputer(burrowId));
const removeKey = (credentialId: string) =>
@@ -405,6 +479,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
};
return `$${price}/${interval}`;
+
+/**
+ * What the buyer agrees to beside the purchase action
+ * (docs/specs/pricing.md -> "Paid-launch requirements"): price and tax,
+ * renewal, cancellation, refunds, and material limits, for the chosen plan.
+ */
+function Terms({ offer }: { offer: PlanOffer }) {
+ const every = offer.interval === "year" ? "every year" : "every month";
+ return (
+
+ {[
+ `$${offer.price} a ${offer.interval}, plus any applicable tax. Stripe shows the total before you pay.`,
+ `Renews automatically ${every} at $${offer.price} until you cancel.${
+ offer.plan === "founding" ? " Your founding price stays for as long as your subscription does." : ""
+ }`,
+ "Cancel any time from your account page. You keep Hosted until the end of the period you paid for.",
+ offer.interval === "year"
+ ? "Ask within 30 days of your first payment or of a yearly renewal for a full refund, which ends the plan."
+ : "Ask within 30 days of your first payment for a full refund, which ends the plan. Monthly renewals are outside this guarantee.",
+ `For one person, including work use, with no device limit. Natural Voice speaks up to ${VOICE_DAILY_CAP} alarms a day; past that, alarms use the system voice.`,
+ ].map((line) => (
+
+ {line}
+
+ ))}
+
+ );
+}
+
+/**
+ * The plans on sale, what each commits the buyer to, and the purchase action
+ * behind affirmative agreement to the current terms and to automatic renewal.
+ */
+export function Checkout({
+ billing,
+ plan,
+ busy,
+ onPurchase,
+}: {
+ billing: BillingSummary;
+ /** The plan the link named, chosen first when on sale. */
+ plan: Plan | null;
+ busy: string;
+ onPurchase: (plan: Plan) => void;
+}) {
+ const offers = planOffers(billing.founding);
+ const [chosen, setChosen] = useState(
+ offers.find((offer) => offer.plan === plan)?.plan ?? offers[0]!.plan,
+ );
+ const [terms, setTerms] = useState(false);
+ const [renewal, setRenewal] = useState(false);
+ const offer = offers.find((each) => each.plan === chosen)!;
+ function submit(event: FormEvent) {
+ event.preventDefault();
+ if (terms && renewal && !busy) onPurchase(chosen);
+ }
+ return (
+
+ );
+}
diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx
index 1eaf9572a..4997bff9c 100644
--- a/hosted/src/HostedPage.tsx
+++ b/hosted/src/HostedPage.tsx
@@ -18,19 +18,28 @@ import {
} from "dormouse-lib/site/docs-tokens";
import { providerNames, type Account, type Computer, type HostedVoice, type Passkey, type Provider, type Session } from "./api";
import { RECENT_LOGIN_WINDOW, TERMS_VERSION } from "../server/policy-constants";
-import { HELP_CLASS, INPUT_CLASS, LABEL_CLASS, LABEL_TEXT_CLASS, PRIMARY_CLASS } from "./classes";
+import {
+ BUTTON_CLASS,
+ HEADING_CLASS,
+ HELP_CLASS,
+ INPUT_CLASS,
+ LABEL_CLASS,
+ LABEL_TEXT_CLASS,
+ PRIMARY_CLASS,
+ TEXT_BUTTON_CLASS,
+} from "./classes";
import { ConfirmDigits } from "./ConfirmDigits";
import { HostedShell } from "./HostedShell";
import { NoPlanOffer } from "./NoPlanOffer";
+import { planName, type BillingSummary, type Plan } from "./billing";
+import { Checkout } from "./Checkout";
+import { FoundersOptIn, PlanConfirmation, PlanSection } from "./Plan";
import type { Enrollment } from "./enrollment";
import type { Notice, PageError } from "./settle";
export const SUBSCRIBE_URL = "https://nedshed.dev/subscribe";
const SELF_HOST_URL = "https://dormouse.sh/self-host/";
-const BUTTON_CLASS = siteButton({ tone: "neutral", size: "fit" });
-const TEXT_BUTTON_CLASS = `inline-flex min-h-11 items-center disabled:opacity-50 ${LINK_CLASS}`;
-const HEADING_CLASS = "mb-2 font-display text-xl";
const ROW_CLASS = `flex min-h-14 items-center justify-between gap-4 py-1.5 ${DASHED_ROW_CLASS}`;
const DETAIL_CLASS = `block text-sm ${MUTED_TEXT_CLASS}`;
const FOOTNOTE_CLASS = `mt-10 text-sm ${MUTED_TEXT_CLASS}`;
@@ -66,8 +75,23 @@ function Ticked({ children }: { children: string }) {
);
}
+/** Which page of the account app, past enrollment, this is. */
+export type View = "account" | "checkout" | "billing";
+
/** App's existing values and callbacks; the controller owns their lifecycle. */
export interface HostedPageProps {
+ view?: View;
+ /** The account's plan, `closed` until checkout opens, null when unread. */
+ billing?: BillingSummary | "closed" | null;
+ /** The plan a checkout link named. */
+ checkoutPlan?: Plan | null;
+ /** Back from Stripe with a checkout this page just confirmed. */
+ confirmed?: boolean;
+ purchase?: (plan: Plan) => void;
+ manageBilling?: () => void;
+ saveFounder?: (shown: boolean, name: string) => void;
+ /** Leaves checkout or billing for the account page. */
+ leaveBilling?: () => void;
session: Session | null;
enabled: Provider[];
accounts: Account[];
@@ -108,7 +132,11 @@ export interface HostedPageProps {
changeEmail: () => void;
}
+const idle = () => {};
+
export function HostedPage({
+ view = "account", billing = null, checkoutPlan = null, confirmed = false,
+ purchase = idle, manageBilling = idle, saveFounder = idle, leaveBilling = idle,
session, enabled, accounts, hostedVoice, turnOffDialog, computers, passkeys,
enrolling, noPlan, loading, busy, error, notice, email, code, enterCode,
comingSoon, fresh, themeControl = false, act, refresh, approve, signOut, clearEnrollment, turnOffVoice,
@@ -121,6 +149,27 @@ export function HostedPage({
}, [enterCode]);
// A lapsed account still sees, and may turn off, a Natural Voice left on.
const voiceShown = !!hostedVoice && (hostedVoice.entitled || hostedVoice.on);
+ const summary = billing !== null && billing !== "closed" ? billing : null;
+ const founders = summary && (
+
+ );
+ const backToAccount = (
+
+ );
+ /** What checkout and billing say before they have a plan to show. */
+ const billingUnread =
+ billing === "closed" ? (
+
Checkout is not open yet.
+ ) : (
+ <>
+
Billing could not load. Retry, or come back to this page later.
+
+ >
+ );
return (
@@ -131,6 +180,14 @@ export function HostedPage({
? enrolling.purpose === "voice"
? "Turn on Natural Voice"
: "Enroll remote control"
+ : view === "checkout"
+ ? session
+ ? "Subscribe to Dormouse Hosted"
+ : "Sign in to subscribe"
+ : view === "billing" && session
+ ? confirmed && summary?.plan
+ ? "You’re subscribed"
+ : "Billing"
: session
? "Your account"
: "Sign in to Dormouse Hosted"}
@@ -150,6 +207,16 @@ export function HostedPage({
? "Dormouse asked to turn on Natural Voice with this account."
: "Dormouse asked to enroll remote control with this account."
: "Sign in to approve the computer that sent you here."
+ : view === "checkout"
+ ? session
+ ? "Natural Voice and the Hosted Relay on every computer you sign in on."
+ : "Sign in or create your account first. Your plan stays chosen."
+ : view === "billing"
+ ? session
+ ? confirmed && summary?.plan
+ ? "Thank you. This page keeps the details of your plan; come back to it any time from your account."
+ : "Your Hosted plan."
+ : "Sign in to see your plan."
: session
? "Manage how you sign in."
: "One account for Dormouse’s hosted services."}
@@ -190,7 +257,7 @@ export function HostedPage({
{enrolling.code ? (
noPlan ? (
<>
-
+
{enrolling.purpose !== "voice" && (
+
+ Choose a plan
+
+ {children}
+ >
+ )}
+ >
+ );
+}
+
+/**
+ * The durable confirmation `/billing` shows for the held plan, as long as the
+ * account holds it (docs/specs/pricing.md -> "Paid-launch requirements"):
+ * the plan and price, renewal, what was agreed and when, how to cancel, and
+ * refunds.
+ */
+export function PlanConfirmation({
+ billing,
+ busy,
+ onManage,
+}: {
+ billing: BillingSummary & { plan: NonNullable };
+ busy: string;
+ onManage: () => void;
+}) {
+ const rows: [string, ReactNode][] = [
+ ["Plan", `${planName(billing.plan)}${billing.price ? ` · ${perInterval(billing.price, interval(billing))}` : ""}`],
+ ["Status", planStatus(billing)],
+ ...(billing.consent
+ ? ([
+ [
+ "You agreed",
+ `To the Terms (version ${billing.consent.termsVersion}) and to automatic renewal, on ${new Date(
+ billing.consent.consentedAt,
+ ).toLocaleString()}.`,
+ ],
+ ] as [string, ReactNode][])
+ : []),
+ [
+ "Cancel",
+ "Any time in Manage billing. You keep Hosted until the end of the period you paid for.",
+ ],
+ [
+ "Refunds",
+ <>
+ {billing.plan === "monthly"
+ ? "Within 30 days of your first payment"
+ : "Within 30 days of your first payment or of a yearly renewal"}
+ , email{" "}
+
+ {SUPPORT}
+ {" "}
+ for a full refund, which ends the plan.
+ >,
+ ],
+ ["Receipt", "Stripe emails a receipt for each payment to your billing email."],
+ ];
+ return (
+
+
+ {rows.map(([term, detail], index) => (
+
+
{term}
+
{detail}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/hosted/src/api.ts b/hosted/src/api.ts
index 8a985c0d5..6946acce1 100644
--- a/hosted/src/api.ts
+++ b/hosted/src/api.ts
@@ -2,6 +2,8 @@ import { providerIds, providerNames } from "../server/providers.js";
import type { ProviderId } from "../server/providers.js";
import { NOT_ENTITLED_ERROR } from "../../remote-lib-common/src/remote/wire.ts";
import { ADMIN_METRICS_PATH, type AdminMetricsBody } from "../server/metric-labels";
+import { CHECKOUT_CLOSED, TERMS_VERSION } from "../server/policy-constants";
+import type { BillingSummary, Plan } from "./billing";
export const providers = providerIds;
export type Provider = ProviderId;
@@ -18,6 +20,8 @@ async function request(
path: string,
init: RequestInit | undefined,
unavailable: string,
+ /** Let a 503 through, for a caller that reads its message. */
+ pass503 = false,
): Promise {
let response: Response;
try {
@@ -29,7 +33,7 @@ async function request(
} catch {
throw new Error("Could not connect. Check your connection and try again.");
}
- if (response.status >= 500) throw new Error(unavailable);
+ if (response.status >= 500 && !(pass503 && response.status === 503)) throw new Error(unavailable);
return response;
}
async function json(path: string, init?: RequestInit): Promise {
@@ -69,9 +73,16 @@ export async function social(provider: Provider, linking: boolean) {
linking ? "link-social" : "sign-in/social",
{ provider },
);
- const url = new URL(result.url);
- if (url.protocol !== "https:")
- throw new Error("Sign-in is temporarily unavailable. Please try again.");
+ leaveFor(result.url, "Sign-in is temporarily unavailable. Please try again.");
+}
+/**
+ * Sends this tab to `href`, an https page the server named (a provider's,
+ * Stripe's), or in development a loopback stand-in such as StripeDev's.
+ */
+function leaveFor(href: string, unavailable: string) {
+ const url = new URL(href);
+ const loopback = import.meta.env.DEV && url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname);
+ if (url.protocol !== "https:" && !loopback) throw new Error(unavailable);
window.location.assign(url.href);
}
@@ -216,3 +227,57 @@ export async function getAdminMetrics(): Promise {
if (!response.ok) throw await refused(response);
return { status: "ok", body: (await response.json()) as AdminMetricsBody };
}
+
+const BILLING_UNAVAILABLE = "Billing is temporarily unavailable. Try again.";
+async function billing(path: string, init?: RequestInit): Promise {
+ return request(`/api/billing${path}`, init, BILLING_UNAVAILABLE, true);
+}
+/** A billing call's error: closed, or the server's message for this page. */
+async function billingRefused(response: Response): Promise {
+ return response.status === 503 && !(await response.clone().json().then((body) => body?.message === CHECKOUT_CLOSED, () => false))
+ ? new Error(BILLING_UNAVAILABLE)
+ : refused(response);
+}
+const sendJson = (method: string, body: unknown): RequestInit => ({
+ method,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+});
+/** This account's plan; `closed` until checkout opens. */
+export async function getBilling(): Promise {
+ const response = await billing("");
+ if (response.ok) return (await response.json()) as BillingSummary;
+ const error = await billingRefused(response);
+ if (error.message === CHECKOUT_CLOSED) return "closed";
+ throw error;
+}
+/**
+ * Starts a checkout of `plan`, agreeing to the current terms and to automatic
+ * renewal, and sends this tab to Stripe.
+ */
+export async function startCheckout(plan: Plan, ref: string | null) {
+ const response = await billing(
+ "/checkout",
+ sendJson("POST", { plan, ...(ref ? { ref } : {}), terms: TERMS_VERSION, autoRenew: true }),
+ );
+ if (!response.ok) throw await billingRefused(response);
+ leaveFor(((await response.json()) as { url: string }).url, BILLING_UNAVAILABLE);
+}
+/** Confirms the checkout Stripe returned from, answering the plan it bought. */
+export async function confirmCheckout(checkout: string): Promise {
+ const response = await billing("/confirm", sendJson("POST", { checkout }));
+ if (!response.ok) throw await billingRefused(response);
+ return (await response.json()) as BillingSummary;
+}
+/** Sends this tab to Stripe's billing portal for this account's subscription. */
+export async function openPortal() {
+ const response = await billing("/portal", { method: "POST" });
+ if (!response.ok) throw await billingRefused(response);
+ leaveFor(((await response.json()) as { url: string }).url, BILLING_UNAVAILABLE);
+}
+/** Joins the founders row under `name`, or leaves it. */
+export async function setFounder(shown: boolean, name: string): Promise {
+ const response = await billing("/founder", sendJson("PUT", shown ? { shown, name } : { shown }));
+ if (!response.ok) throw await billingRefused(response);
+ return (await response.json()) as BillingSummary;
+}
diff --git a/hosted/src/billing.test.ts b/hosted/src/billing.test.ts
new file mode 100644
index 000000000..8476d8e26
--- /dev/null
+++ b/hosted/src/billing.test.ts
@@ -0,0 +1,69 @@
+// @vitest-environment jsdom
+// @vitest-environment-options {"url":"https://hosted.example.test/"}
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { CHECKOUT_KEY, CHECKOUT_TTL_MS, planOffers, takeCheckout, takeConfirmation } from "./billing";
+import { FOUNDING_LADDER, LIST_ANNUAL, LIST_MONTHLY } from "../../website/src/lib/hosted-pricing.ts";
+
+const at = (path: string) => history.replaceState(null, "", path);
+const path = () => location.pathname + location.search;
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ sessionStorage.clear();
+});
+afterEach(() => vi.useRealTimers());
+
+it("takes a checkout link's plan and ref off the address bar, an unknown ref as other", () => {
+ at("/checkout?plan=yearly&ref=settings-voice");
+ expect(takeCheckout()).toEqual({ plan: "yearly", ref: "settings-voice" });
+ expect(path()).toBe("/checkout");
+ at("/checkout?plan=lifetime&ref=attacker-chosen");
+ expect(takeCheckout()).toEqual({ plan: null, ref: "other" });
+ expect(path()).toBe("/checkout");
+});
+
+it("keeps the checkout through a provider's sign-in for ten minutes, and nowhere else", () => {
+ at("/checkout?plan=founding&ref=hosted-no-plan");
+ takeCheckout();
+ expect(Object.keys(JSON.parse(sessionStorage.getItem(CHECKOUT_KEY)!)).sort()).toEqual(["at", "plan", "ref"]);
+ at("/enroll");
+ expect(takeCheckout()).toBeNull();
+ at("/billing");
+ expect(takeCheckout()).toBeNull();
+ for (const back of ["/account", "/login", "/checkout"]) {
+ at(back);
+ expect(takeCheckout(), back).toEqual({ plan: "founding", ref: "hosted-no-plan" });
+ }
+ vi.advanceTimersByTime(CHECKOUT_TTL_MS);
+ at("/account");
+ expect(takeCheckout()).toBeNull();
+ expect(sessionStorage.getItem(CHECKOUT_KEY)).toBeNull();
+ // A bare checkout link starts one of its own.
+ at("/checkout");
+ expect(takeCheckout()).toEqual({ plan: null, ref: null });
+ at("/account");
+ expect(takeCheckout()).toEqual({ plan: null, ref: null });
+});
+
+it("takes the checkout Stripe returned from off the address bar, ending the kept checkout", () => {
+ at("/checkout?plan=monthly");
+ takeCheckout();
+ at("/billing?checkout=op_123");
+ expect(takeConfirmation()).toBe("op_123");
+ expect(path()).toBe("/billing");
+ expect(sessionStorage.getItem(CHECKOUT_KEY)).toBeNull();
+ at("/billing");
+ expect(takeConfirmation()).toBeNull();
+ at("/account?checkout=op_123");
+ expect(takeConfirmation()).toBeNull();
+ expect(path()).toBe("/account?checkout=op_123");
+});
+
+it("offers founding at the open cohort's step, and drops it once founding closes", () => {
+ expect(planOffers({ cohort: 2, seatsLeft: 7 }).map(({ plan, price }) => [plan, price])).toEqual([
+ ["founding", FOUNDING_LADDER[2]],
+ ["yearly", LIST_ANNUAL],
+ ["monthly", LIST_MONTHLY],
+ ]);
+ expect(planOffers(null).map(({ plan }) => plan)).toEqual(["yearly", "monthly"]);
+});
diff --git a/hosted/src/billing.ts b/hosted/src/billing.ts
new file mode 100644
index 000000000..1c7061eaf
--- /dev/null
+++ b/hosted/src/billing.ts
@@ -0,0 +1,143 @@
+import { HOSTED_REF_PARAM, isHostedRef, type HostedRef } from "dormouse-lib/lib/hosted-links";
+import {
+ FOUNDING_LADDER,
+ HOSTED_MONTHLY,
+ HOSTED_YEARLY,
+ LIST_ANNUAL,
+} from "../../website/src/lib/hosted-pricing.ts";
+import { BILLING_PLANS } from "../server/policy-constants";
+
+export type Plan = (typeof BILLING_PLANS)[number];
+export const isPlan = (plan: unknown): plan is Plan => (BILLING_PLANS as readonly unknown[]).includes(plan);
+
+/** `GET /api/billing` (docs/specs/hosted.md -> "Billing"). */
+export interface BillingSummary {
+ /** The subscription Stripe still holds, paid up or not; null without one. */
+ plan: Plan | null;
+ /** What that subscription charges per interval, in whole US dollars. */
+ price: number | null;
+ /** Whether that subscription grants access now. */
+ active: boolean;
+ until: string | null;
+ renews: boolean;
+ /** Whether the account has Hosted now, by its subscription or a comp. */
+ entitled: boolean;
+ /** A payment dispute is open, which pauses the subscription. */
+ suspended: boolean;
+ /** The founding cohort on sale; null once founding has closed. */
+ founding: { cohort: number; seatsLeft: number } | null;
+ /** `offered` while a founding subscription grants access; `name` while shown in the founders row. */
+ founder: { offered: boolean; name: string | null };
+ /** What the account agreed to when it bought the held subscription. */
+ consent: { termsVersion: string; consentedAt: string } | null;
+}
+
+/** What `/checkout` arrived with, kept through a provider's sign-in. */
+export interface CheckoutIntent {
+ plan: Plan | null;
+ /** Which link sent the buyer: one of `HOSTED_REFS`, `other`, or none. */
+ ref: HostedRef | "other" | null;
+}
+
+// Provider sign-in returns to `/account`, so the plan and ref ride this tab's
+// storage for a little while; nothing else does.
+export const CHECKOUT_KEY = "dormouse-hosted-checkout";
+export const CHECKOUT_TTL_MS = 10 * 60_000;
+
+function save(intent: CheckoutIntent) {
+ try {
+ sessionStorage.setItem(CHECKOUT_KEY, JSON.stringify({ ...intent, at: Date.now() }));
+ } catch {
+ // Storage blocked: provider sign-in lands on the account page instead.
+ }
+}
+
+function stored(): CheckoutIntent | null {
+ let value: unknown;
+ try {
+ value = JSON.parse(sessionStorage.getItem(CHECKOUT_KEY) ?? "null");
+ } catch {
+ return null;
+ }
+ const { plan, ref, at } = (value ?? {}) as { plan?: unknown; ref?: unknown; at?: unknown };
+ const age = typeof at === "number" ? Date.now() - at : Infinity;
+ if (!(age >= 0 && age < CHECKOUT_TTL_MS)) {
+ forgetCheckout();
+ return null;
+ }
+ return { plan: isPlan(plan) ? plan : null, ref: isHostedRef(ref) || ref === "other" ? ref : null };
+}
+
+/** Ends the checkout this tab was on, so the account page stays the account page. */
+export function forgetCheckout() {
+ try {
+ sessionStorage.removeItem(CHECKOUT_KEY);
+ } catch {
+ // Nothing was kept.
+ }
+}
+
+/**
+ * The checkout this load continues, or null. On `/checkout` its `plan` and
+ * `ref` come off the address bar, so a copied link never carries the ref;
+ * back from a provider's sign-in (`/account`, or `/login` after a failed
+ * one), they come from storage while still fresh.
+ */
+export function takeCheckout(): CheckoutIntent | null {
+ const { pathname, search } = location;
+ if (pathname === "/checkout") {
+ const params = new URLSearchParams(search);
+ const kept = !params.has("plan") && !params.has(HOSTED_REF_PARAM) && stored();
+ if (kept) return kept;
+ const plan = params.get("plan");
+ const ref = params.get(HOSTED_REF_PARAM);
+ const intent: CheckoutIntent = {
+ plan: isPlan(plan) ? plan : null,
+ ref: ref === null ? null : isHostedRef(ref) ? ref : "other",
+ };
+ history.replaceState(null, "", pathname);
+ save(intent);
+ return intent;
+ }
+ return pathname === "/account" || pathname === "/login" ? stored() : null;
+}
+
+/**
+ * The checkout Stripe returned from on `/billing?checkout=`, taken off the
+ * address bar so a reload or a bookmark never confirms it again; null
+ * anywhere else.
+ */
+export function takeConfirmation(): string | null {
+ if (location.pathname !== "/billing") return null;
+ const checkout = new URLSearchParams(location.search).get("checkout");
+ history.replaceState(null, "", location.pathname);
+ if (checkout !== null) forgetCheckout();
+ return checkout || null;
+}
+
+/** One plan as the checkout page offers it, at today's price. */
+export interface PlanOffer {
+ plan: Plan;
+ name: string;
+ price: number;
+ interval: "month" | "year";
+ /** The list price struck beside founding. */
+ listPrice?: number;
+}
+
+/**
+ * Each plan on sale, founding first while open, at the price checkout will
+ * charge: founding at the open cohort's step, which the server reads.
+ */
+export function planOffers(founding: BillingSummary["founding"]): PlanOffer[] {
+ const step = founding && FOUNDING_LADDER[founding.cohort];
+ return [
+ ...(step ? [{ plan: "founding" as const, name: "Founding", price: step, interval: "year" as const, listPrice: LIST_ANNUAL }] : []),
+ { plan: "yearly", name: HOSTED_YEARLY.name, price: HOSTED_YEARLY.price, interval: "year" },
+ { plan: "monthly", name: HOSTED_MONTHLY.name, price: HOSTED_MONTHLY.price, interval: "month" },
+ ];
+}
+
+/** A plan's name as the account page shows it. */
+export const planName = (plan: Plan) =>
+ plan === "founding" ? "Founding" : plan === "yearly" ? HOSTED_YEARLY.name : HOSTED_MONTHLY.name;
diff --git a/hosted/src/classes.ts b/hosted/src/classes.ts
index 6b1ebaa60..ab810a580 100644
--- a/hosted/src/classes.ts
+++ b/hosted/src/classes.ts
@@ -2,7 +2,9 @@ import {
ACTION_CLASS,
FIELD_CLASS,
FIELD_TEXT_CLASS,
+ LINK_CLASS,
MUTED_TEXT_CLASS,
+ siteButton,
} from "dormouse-lib/site/docs-tokens";
/** Hosted's form pieces, composed from the docs pages' tokens. */
@@ -12,3 +14,6 @@ export const LABEL_TEXT_CLASS = `font-display text-sm ${MUTED_TEXT_CLASS}`;
export const LABEL_CLASS = `mt-5 mb-2 block ${LABEL_TEXT_CLASS}`;
export const INPUT_CLASS = `${FIELD_CLASS} ${FIELD_TEXT_CLASS} read-only:border-dashed`;
export const HELP_CLASS = `mb-4 ${MUTED_TEXT_CLASS}`;
+export const BUTTON_CLASS = siteButton({ tone: "neutral", size: "fit" });
+export const TEXT_BUTTON_CLASS = `inline-flex min-h-11 items-center disabled:opacity-50 ${LINK_CLASS}`;
+export const HEADING_CLASS = "mb-2 font-display text-xl";
diff --git a/hosted/src/main.tsx b/hosted/src/main.tsx
index 61f075828..46a2974ec 100644
--- a/hosted/src/main.tsx
+++ b/hosted/src/main.tsx
@@ -1,6 +1,7 @@
import { createRoot } from "react-dom/client";
import { AdminMetrics, isAdminMetricsPath } from "./AdminMetrics";
import { App } from "./App";
+import { takeCheckout, takeConfirmation } from "./billing";
import { takeEnrollment } from "./enrollment";
import "./index.css";
@@ -9,5 +10,7 @@ if (isAdminMetricsPath(location.pathname)) root.render();
else {
// Taken before anything renders; a later fragment change on `/enroll` is App's.
const enrollment = takeEnrollment();
- root.render();
+ const confirming = takeConfirmation();
+ const checkout = enrollment ? null : takeCheckout();
+ root.render();
}
From e60895edc79eb3c84530338caf987c74b4a919c5 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 05:21:56 -0700
Subject: [PATCH 3/6] Hosted: stories for checkout, billing, and the plan
section
Co-Authored-By: Claude Opus 5.5
---
hosted/src/Plan.tsx | 2 +-
hosted/src/stories/Account.stories.tsx | 17 ++++++++-
hosted/src/stories/Billing.stories.tsx | 42 +++++++++++++++++++++++
hosted/src/stories/Checkout.stories.tsx | 39 +++++++++++++++++++++
hosted/src/stories/Enrollment.stories.tsx | 10 +++++-
hosted/src/stories/fixtures.tsx | 13 +++++++
6 files changed, 120 insertions(+), 3 deletions(-)
create mode 100644 hosted/src/stories/Billing.stories.tsx
create mode 100644 hosted/src/stories/Checkout.stories.tsx
diff --git a/hosted/src/Plan.tsx b/hosted/src/Plan.tsx
index 183492011..f5b33f43a 100644
--- a/hosted/src/Plan.tsx
+++ b/hosted/src/Plan.tsx
@@ -97,7 +97,7 @@ export function FoundersOptIn({
)}
{valid
- ? "The row shows this name and your profile picture, if you have one. Untick to leave it at any time."
+ ? "The row shows the name you choose and your profile picture, if you have one. Untick to leave it at any time."
: "Use 1 to 64 characters."}
>
) : (
-
+ // A new founding step asks for agreement again, both boxes unticked.
+
)}
{backToAccount}
diff --git a/hosted/src/Plan.tsx b/hosted/src/Plan.tsx
index f5b33f43a..fd03f6b5e 100644
--- a/hosted/src/Plan.tsx
+++ b/hosted/src/Plan.tsx
@@ -152,7 +152,7 @@ export function PlanSection({
/**
* The durable confirmation `/billing` shows for the held plan, as long as the
- * account holds it (docs/specs/pricing.md -> "Paid-launch requirements"):
+ * account holds it (docs/specs/pricing.md -> "Checkout and entitlement"):
* the plan and price, renewal, what was agreed and when, how to cancel, and
* refunds.
*/
diff --git a/hosted/src/api.ts b/hosted/src/api.ts
index 6946acce1..73ba1655f 100644
--- a/hosted/src/api.ts
+++ b/hosted/src/api.ts
@@ -2,7 +2,7 @@ import { providerIds, providerNames } from "../server/providers.js";
import type { ProviderId } from "../server/providers.js";
import { NOT_ENTITLED_ERROR } from "../../remote-lib-common/src/remote/wire.ts";
import { ADMIN_METRICS_PATH, type AdminMetricsBody } from "../server/metric-labels";
-import { CHECKOUT_CLOSED, TERMS_VERSION } from "../server/policy-constants";
+import { CHECKOUT_CLOSED, PRICE_CHANGED, TERMS_VERSION } from "../server/policy-constants";
import type { BillingSummary, Plan } from "./billing";
export const providers = providerIds;
@@ -251,16 +251,21 @@ export async function getBilling(): Promise {
if (error.message === CHECKOUT_CLOSED) return "closed";
throw error;
}
+/** A checkout refused because `price` is no longer what it would charge. */
+export class PriceChangedError extends Error {}
/**
- * Starts a checkout of `plan`, agreeing to the current terms and to automatic
+ * Starts a checkout of `plan` at the `price` the page showed, agreeing to the current terms and to automatic
* renewal, and sends this tab to Stripe.
*/
-export async function startCheckout(plan: Plan, ref: string | null) {
+export async function startCheckout(plan: Plan, price: number, ref: string | null) {
const response = await billing(
"/checkout",
- sendJson("POST", { plan, ...(ref ? { ref } : {}), terms: TERMS_VERSION, autoRenew: true }),
+ sendJson("POST", { plan, ...(ref ? { ref } : {}), terms: TERMS_VERSION, autoRenew: true, price }),
);
- if (!response.ok) throw await billingRefused(response);
+ if (!response.ok) {
+ const error = await billingRefused(response);
+ throw response.status === 409 && error.message === PRICE_CHANGED ? new PriceChangedError(error.message) : error;
+ }
leaveFor(((await response.json()) as { url: string }).url, BILLING_UNAVAILABLE);
}
/** Confirms the checkout Stripe returned from, answering the plan it bought. */
diff --git a/hosted/src/stories/Checkout.stories.tsx b/hosted/src/stories/Checkout.stories.tsx
index 331b319c3..45fc64b59 100644
--- a/hosted/src/stories/Checkout.stories.tsx
+++ b/hosted/src/stories/Checkout.stories.tsx
@@ -21,6 +21,10 @@ export const Closed: Story = { args: { billing: "closed" } };
export const Unavailable: Story = { args: { billing: null } };
export const AlreadySubscribed: Story = { args: { billing: FOUNDING } };
export const Opening: Story = { args: { busy: "checkout" } };
+/** The cohort sold out while the page was open: the next step, asked again. */
+export const PriceChanged: Story = {
+ args: { billing: { ...NO_PLAN, founding: { cohort: 1, seatsLeft: 100 } }, notice: { text: "The price has changed. Check the new price and agree again.", error: true } },
+};
export const Refused: Story = { args: { error: { read: false, message: "The terms have changed. Reload this page to read them." } } };
/** The purchase action stays off until both agreements are ticked. */
export const Agreeing: Story = {
@@ -33,7 +37,7 @@ export const Agreeing: Story = {
await expect(pay).toBeDisabled();
await canvas.user.click(canvas.getByLabelText(/renews automatically/));
await canvas.user.click(pay);
- await expect(args.purchase).toHaveBeenCalledWith("founding");
+ await expect(args.purchase).toHaveBeenCalledWith("founding", 50);
canvas.document.defaultView!.scrollTo(0, 0);
},
};
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index 96f3ce89d..8776459ad 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -12,7 +12,7 @@
"docs/specs/dor-tools-builtin.md": 1750,
"docs/specs/dor-tools-lib.md": 350,
"docs/specs/glossary.md": 2950,
- "docs/specs/hosted.md": 5600,
+ "docs/specs/hosted.md": 5650,
"docs/specs/layout.md": 7250,
"docs/specs/mobile-terminal-ui.md": 1250,
"docs/specs/mouse-and-clipboard.md": 4200,
From 0d2265324310c5eced34d756c97a30d72b4d10bd Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 15:33:31 -0700
Subject: [PATCH 6/6] Hosted: say the no-charge plan copy is the admin comp
Entitled without a plan, or entitled on a lapsed plan, can only be the
ADMIN_EMAIL comp, so the copy says so and the stories are named for it.
Co-Authored-By: Claude Opus 5.5
---
hosted/src/App.test.tsx | 2 +-
hosted/src/Plan.tsx | 10 +++++++---
hosted/src/stories/Account.stories.tsx | 6 ++++--
3 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx
index d8ae0be42..9c72f5e4c 100644
--- a/hosted/src/App.test.tsx
+++ b/hosted/src/App.test.tsx
@@ -459,7 +459,7 @@ describe("the plan section", () => {
it.each([
[false, "so Natural Voice and the Hosted Relay are paused"],
- [true, "Your account keeps Hosted at no charge"],
+ [true, "Hosted stays included with this account (admin)"],
])("keeps Manage billing for a payment that failed, entitled %s", async (entitled, copy) => {
vi.mocked(api.getBilling).mockResolvedValue({ ...FOUNDING, active: false, entitled, founder: { offered: false, name: null } });
await mount(null);
diff --git a/hosted/src/Plan.tsx b/hosted/src/Plan.tsx
index fd03f6b5e..c38b5ef81 100644
--- a/hosted/src/Plan.tsx
+++ b/hosted/src/Plan.tsx
@@ -9,13 +9,16 @@ const SUPPORT = "support@dormouse.sh";
const day = (iso: string) => new Date(iso).toLocaleDateString();
const interval = (billing: BillingSummary) => (billing.plan === "monthly" ? "month" : "year");
-/** The held plan's state in one sentence; its lapsed copy branches on `entitled`. */
+/**
+ * The held plan's state in one sentence. A lapsed plan with `entitled` still
+ * true can only be the `ADMIN_EMAIL` comp (hosted/server/entitlement.ts).
+ */
export function planStatus(billing: BillingSummary): string {
if (billing.suspended)
return "Paused while a payment dispute is open. It resumes if the dispute closes in your favor.";
if (!billing.active)
return billing.entitled
- ? "Your last payment did not go through. Your account keeps Hosted at no charge; update your card in Manage billing."
+ ? "Your last payment did not go through. Hosted stays included with this account (admin); update your card in Manage billing."
: "Your last payment did not go through, so Natural Voice and the Hosted Relay are paused. Update your card in Manage billing.";
if (!billing.until) return "Active.";
return billing.renews
@@ -134,7 +137,8 @@ export function PlanSection({
{children}
>
) : billing.entitled ? (
-
Your account has Hosted at no charge.
+ // Entitled without a plan: only the `ADMIN_EMAIL` comp.
+
Hosted is included with this account (admin).
) : (
<>
diff --git a/hosted/src/stories/Account.stories.tsx b/hosted/src/stories/Account.stories.tsx
index 41b357702..a2f66229b 100644
--- a/hosted/src/stories/Account.stories.tsx
+++ b/hosted/src/stories/Account.stories.tsx
@@ -60,10 +60,12 @@ export const PlanEnding: Story = { args: { ...ACCOUNT, billing: { ...FOUNDING, r
export const PlanPaymentFailed: Story = {
args: { ...ACCOUNT, billing: { ...FOUNDING, active: false, entitled: false, founder: { offered: false, name: null } } },
};
-export const PlanPaymentFailedComp: Story = {
+/** Only the `ADMIN_EMAIL` comp can stay entitled on a lapsed plan. */
+export const PlanPaymentFailedAdminComp: Story = {
args: { ...ACCOUNT, billing: { ...FOUNDING, active: false, founder: { offered: false, name: null } } },
};
export const PlanSuspended: Story = { args: { ...ACCOUNT, billing: { ...FOUNDING, entitled: false, suspended: true } } };
-export const PlanComp: Story = { args: { ...ACCOUNT, billing: { ...NO_PLAN, entitled: true } } };
+/** Only the `ADMIN_EMAIL` comp is entitled without a plan. */
+export const PlanAdminComp: Story = { args: { ...ACCOUNT, billing: { ...NO_PLAN, entitled: true } } };
export const LapsedFounder: Story = { args: { ...ACCOUNT, billing: { ...NO_PLAN, founder: { offered: false, name: "Alex" } } } };
export const PlanPhone: Story = { args: { ...ACCOUNT, billing: FOUNDING, phone: true } };