diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md
index 716bab2fd..5cc64312d 100644
--- a/.github/audit/hosted.md
+++ b/.github/audit/hosted.md
@@ -46,7 +46,7 @@ Be adversarial, and go past the `FAIL IF` list; a bare section name is `docs/spe
- **Can a Hosted login become terminal access, or an account become someone else's?** Trace linking, a revoked login's callback, an unused or unknown provider credential, and every path from a login toward a Burrow ACL grant ("Account boundary"; the relay Worker's cookie rule is "Relay boundary"). Pocket and `/connect/` share the relay origin; check what each page's policy lets it reach of the other.
- **Can one account's relay socket reach another's?** Trace an upgrade through `relaySocketRoutes` (`hosted/server/relay-sockets.ts`) into `RelayRoom` (`hosted/server/relay-room.ts`) against "Relay boundary". Look for routing state kept in memory that a hibernated object would lose, a socket torn down twice or routed after its close began, a frame bounded in characters rather than bytes, a `ct` touched outside the shared frame layer's field copy, a Client cap one socket can evict past, a session that outlives its alarm, a ping that wakes the object, a Burrow socket accepted on a row removed after the token check, and a removed or de-entitled Burrow's socket outliving the sweep.
- **Can the rendezvous become more than a handshake pipe?** Trace a frame through `OneTimeRoom` against "Rendezvous boundary". Look for a second phone admitted across an await or a hibernation, a room that outlives its alarm, a web page that can mint a room, a join from another origin, a room id a caller can choose, and a limit a caller can step around. The relay is same-site with the account, so account cookies can ride the phone's upgrade.
-- **Can billing grant what Stripe did not sell, or end what it should only suspend?** Trace checkout, confirm, the webhook, and the resync in `hosted/server/billing-routes.ts` and `hosted/server/billing.ts` against "Account boundary": a browser-chosen Price, owner, or return URL, an unsigned or replayed event, a 2xx before the commit, a count a redelivery repeats, a stale dispute event read as current, a partial refund, or a refund of an earlier charge, that ends a subscription, a failed count that fails the event, and a lapsed or disputed subscription that still admits.
+- **Can billing grant what Stripe did not sell, or end what it should only suspend?** Trace checkout, confirm, the webhook, and the resync in `hosted/server/billing-routes.ts` and `hosted/server/billing.ts` against "Account boundary": a browser-chosen Price, owner, or return URL, an unsigned or replayed event, a 2xx before the commit, a count a redelivery repeats, a stale dispute event read as current, a partial refund, or a refund of an earlier charge, that ends a subscription, a failed count that fails the event, and a lapsed or disputed subscription that still admits; and a checkout URL answered before its consent row, or a founders row for a login without a current founding subscription.
- **Does anything from the test or preview build reach production?** Trace the production entries and the preview configuration and workers against "Deployment boundary". Preview credentials are `ci-and-secrets`' (`docs/specs/security-ci.md` -> "Hosted Deployments").
Does the shipped code still match what the spec and this section claim? Spec drift is a finding; say which side is wrong.
diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md
index de0fb83ec..3e79edb0a 100644
--- a/docs/specs/hosted.md
+++ b/docs/specs/hosted.md
@@ -49,11 +49,11 @@ Source of truth: `hosted/server/providers.js`; `authPolicy` / `providerBindings`
**Must link the account footer to the public Hosted privacy policy and terms.**
-**Must show configured sign-in methods only.** Login tokens never enter browser storage, and only public identity fields render, without provider images or external assets.
+**Must show configured sign-in methods only.** Login tokens never enter browser storage, and only public identity fields render, without provider images or external assets. **`/checkout` keeps only its plan and ref in `sessionStorage`, for ten minutes**, so they survive a provider's sign-in, which returns to `/account`; `/billing` confirms the `?checkout=` Stripe returns with once and strips it.
**Must be built from the dormouse.sh reference pages' own design** (`lib/src/site/`), never a copy of it: their theme and picker, offered without the theme store, the site's Ubuntu faces served from Hosted's own origin, and the site header. It loads no marketing scripts or analytics.
-Source of truth: `App` in `hosted/src/App.tsx`; `useDocsThemedBody` in `lib/src/site/docs-paint.ts`.
+Source of truth: `App` in `hosted/src/App.tsx`; `takeCheckout` in `hosted/src/billing.ts`; `useDocsThemedBody` in `lib/src/site/docs-paint.ts`.
## Terms acceptance
@@ -80,10 +80,11 @@ The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` ->
| Route | Credential | Success |
|---|---|---|
-| `GET /api/billing` | login cookie | 200 `{ plan, active, until, renews, entitled, suspended, founding }`; `plan` names a subscription Stripe still holds |
-| `POST /api/billing/checkout` | login cookie, exact `Origin`, JSON `{ plan, ref? }` | 200 `{ url }` of Stripe Checkout |
+| `GET /api/billing` | login cookie | 200 `{ plan, price, active, until, renews, entitled, suspended, founding, founder, consent }`; `plan` names a subscription Stripe still holds |
+| `POST /api/billing/checkout` | login cookie, exact `Origin`, JSON `{ plan, ref?, terms, autoRenew, price }` | 200 `{ url }` of Stripe Checkout |
| `POST /api/billing/confirm` | login cookie, exact `Origin`, JSON `{ checkout }` | 200, the `GET` body |
| `POST /api/billing/portal` | login cookie, exact `Origin` | 200 `{ url }` of the customer portal |
+| `PUT /api/billing/founder` | login cookie, exact `Origin`, JSON `{ shown, name? }` | 200, the `GET` body |
| `POST /api/billing/webhook` | `Stripe-Signature` over the raw body | 200 once `webhook()` committed |
Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 without a login, and cap a body at 4 KiB.
@@ -91,6 +92,8 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401
- **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, every route past the login gate answers 503 `CHECKOUT_CLOSED` and the resync reads nothing. **Must refuse a founding list whose length differs from `FOUNDING_LADDER`**, or a malformed or repeated Price. Live mode follows the key's prefix.
- **Never offer a trial or Managed Payments; must enable Stripe Tax on every Checkout.**
- **Never take a Price, customer, owner, quantity, or return URL from a request**: checkout takes a plan name and a `ref`, normalized as `checkoutRef` does; the owner is the login, its email only once verified (else Checkout asks), and Stripe returns to `/billing`. A checkout open for another plan is expired first; a held subscription refuses another with 409.
+- **Must start a checkout only after agreement to `TERMS_VERSION` and to automatic renewal at the `price` the page showed, recorded in `dormouse_checkout_consents` before its URL is answered**; another version answers 409 `TERMS_CHANGED`, and a checkout whose Price `publishedPrice` does not read as `price` is expired and answers 409 `PRICE_CHANGED`. `GET` shows the latest while a subscription is held.
+- **Must add or rename a founders-row name (`dormouse_founders`) only while `subscribedSql` holds on the founding Prices** (else 409) and `isFounderName` accepts it; leaving always works, a rename keeps `shownSince`. Checks: `docs/specs/security-hosted.md` -> "Account boundary".
- **Must verify the webhook's signature over the raw body before any write** and answer 2xx only once `webhook()` has committed, any other failure 503, which Stripe retries.
- **Must end at once the subscription a fully refunded charge or a lost dispute's charge paid for**, found through its invoice, while it is the owner's current one: commented `ENDED_FOR_REFUND`, after the commit and on every delivery until ended. A partial refund ends nothing, nor does a charge for an earlier subscription or none, which is logged for the operator.
- **Must suspend, never end, on an open dispute**, by its current status read from Stripe, not the event (`dormouse_billing_disputes`); closing in the account's favor restores it.
@@ -299,7 +302,7 @@ Source of truth: `hosted/server/metric-labels.ts`; `recordMetric` and `authMetri
## Development and release
-**Must run local development with `dor tool hosted` inside Dormouse.** Its loopback `http://localhost:` origin serves Vite and Node auth on a disposable development database, with the account's Natural Voice and Relay routes but never the voice origin's. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. **It also runs the relay Worker on that database, with this origin as `ACCOUNT_ORIGIN`** (`docs/specs/one-time.md` -> "Dev loop").
+**Must run local development with `dor tool hosted` inside Dormouse.** Its loopback `http://localhost:` origin serves Vite and Node auth on a disposable development database, with the account's Natural Voice, Relay, and billing routes but never the voice origin's; billing runs on StripeDev, which takes no card. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. **It also runs the relay Worker on that database, with this origin as `ACCOUNT_ORIGIN`** (`docs/specs/one-time.md` -> "Dev loop").
**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Only test entries inject deterministic Better Auth. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery.
diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md
index 3b1533033..0a8a65f43 100644
--- a/docs/specs/pricing.md
+++ b/docs/specs/pricing.md
@@ -3,7 +3,7 @@
> - See `docs/specs/glossary.md` for Burrow / Client / Relay and Pane / Session vocabulary.
> - **Owns:** the plans, the founding ladder, what a plan grants, how a desktop proves membership, the managed-voice boundary, and the content contract of the Hosted page.
> - **Defers:** page chrome, rail, and link obligations to `docs/specs/website-docs.md` -> "Reference page chrome"; the Hosted Relay's accounts, enrollment, and entitlement, and managed voice's routes, to `docs/specs/hosted.md`; the cloud-hosted trust boundary to `docs/specs/security-remote.md` -> "Cloud-hosted mode"; alarm delivery to `docs/specs/alert.md` -> "Spoken alarms".
-> - **Status:** the Hosted page publishes the plans and the FAQ, a desktop enrolls with Hosted for managed voice and the Hosted Relay, and the account's subscription is the entitlement, with billing closed until checkout opens; checkout itself, and everything else that takes money, is under [Future](#future).
+> - **Status:** the Hosted page publishes the plans and the FAQ, a desktop enrolls with Hosted for managed voice and the Hosted Relay, the account's subscription is the entitlement, and the account pages that sell are built, with billing closed until checkout opens; opening it, and everything else that takes money, is under [Future](#future).
## The Hosted page
@@ -58,15 +58,19 @@ Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; the `/pricing` rule
- **Cohorts close by count, never by date**: completed purchases at the billing provider; a purchase ending within the 30-day refund window, as a full refund or finally reversed payment ends it ("Checkout"), returns its seat to its cohort. **Never reopen the ladder at a lower step** once a cohort has closed. **When a cohort closes the price rises one step and the counter resets to 100**, and **founding closes only when the ladder reaches list**: founding means bought at launch pricing, and the Hosted Relay shipping does not close it.
- **Checkout honors the price it opened at.** Concurrent checkouts may oversell a cohort by a few seats; the overage is the customer's, and the next cohort still opens at a full 100.
- **A full refund or a finally reversed payment ends the subscription it paid for**, so the next request is refused. A partial refund or billing correction never ends it, and an open dispute only suspends it until it closes.
+- **Checkout starts from a Hosted account**: `/checkout` on the account origin asks for sign-in first, so the subscription belongs to an account from its first event.
+- **Must obtain affirmative agreement to the versioned terms and express consent to automatic renewal at the purchase action**, two unticked boxes, disclosing beside it the price and tax, the interval and renewal price, cancellation, the refund conditions, and the material limits: one individual including work use, no device limit, and managed voice's daily cap. Checkout refuses a Price other than the one shown and records the consent with it (`docs/specs/hosted.md` -> "Billing").
+- **Must keep a durable confirmation at `/billing` while the plan is held**: the plan and price, renewal, the terms version agreed and when, how to cancel, and refunds; Stripe's receipt emails carry each payment.
+- **Founding offers the founders-row opt-in, unticked, once bought**; the account can leave at any time.
-Source of truth: `withBilling` and `openCohort` in `hosted/server/billing.ts`; `docs/specs/hosted.md` -> "Billing" owns the rest.
+Source of truth: `withBilling` and `openCohort` in `hosted/server/billing.ts`; `Checkout` in `hosted/src/Checkout.tsx`; `PlanConfirmation` in `hosted/src/Plan.tsx`; `docs/specs/hosted.md` -> "Billing" owns the rest.
## Future
**Scope: hosted-sales** — what remains, in staged order:
1. **The cohort endpoint** the page already calls: the open cohort's seats and the opted-in founders, avatars proxied onto this origin.
-2. **Checkout** ("Checkout"): the account pages that sell, then opening billing.
+2. **Opening checkout** ("Checkout"): the website's buy buttons to `/checkout`, then the Stripe bindings.
3. **Managed voice for members**: one voice per Pane.
4. **Renewal, cancellation, and refund** paths.
@@ -98,10 +102,8 @@ What each plan grants once checkout can sell it; [Published prices](#published-p
The rest of "Checkout and entitlement", once checkout can sell:
-- **Checkout starts from a Hosted account**: a buy button lands on the account origin, which asks for sign-in first, so the subscription belongs to an account from its first event.
-- **Founding checkout offers the founders-row opt-in, unticked**; the account can withdraw it at any time.
- **The success page asks the four Van Westendorp questions**, optional and unsent until answered: too expensive to consider, too cheap to trust, expensive but would consider, a bargain. Their answers inform later list changes.
-- **Must license one individual, including work use, with no device limit and no per-device charge**; enrollment's abuse ceiling is far above what a person owns (`docs/specs/hosted.md` -> "Burrow enrollment"). **Must disclose material usage limits before purchase**, including managed voice's daily cap.
+- **Must license one individual, including work use, with no device limit and no per-device charge**; enrollment's abuse ceiling is far above what a person owns (`docs/specs/hosted.md` -> "Burrow enrollment").
### Managed voice
@@ -125,7 +127,7 @@ Part of **hosted-sales**; these remain unimplemented launch gates, not claims ab
EEA/UK representative appointment is excluded from the internal release gate by operator decision; applicable legal obligations remain unchanged (rationale).
-- **Must obtain affirmative agreement to versioned terms and express consent to automatic renewal before charging**, disclosing price, taxes, interval, refund conditions, cancellation, and material limits beside the purchase action; retain the accepted version, offered limits, and consent evidence and send a durable confirmation.
+- **Must confirm Stripe's receipt emails are on, and that they and the `/billing` confirmation meet each sales territory's durable-confirmation rule**, before charging; consent at the purchase action is "Checkout and entitlement".
- **Must provide direct online cancellation and a support cancellation path when account access is lost.** **Must cancel future renewals as part of account closure**, explaining remaining access and refund eligibility before completing closure; verification cannot require account recovery.
- **Must send jurisdiction-required renewal, annual, and price-change notices with cancellation instructions.** For California consumers, annual-term renewal notices are 15–45 days before renewal and fee-change notices 7–30 days before effectiveness; annual reminders also apply to monthly plans (rationale).
- **Must route subscription notices to the maintained billing email**, including provider-only accounts without a sign-in email; use the account contact email for other notices, or show them at sign-in when none exists. **Must record notice delivery or presentation before starting a notice period**, and provide any additional legally required notice or consent process.
diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md
index 9197b424c..506f80e4b 100644
--- a/docs/specs/security-hosted.md
+++ b/docs/specs/security-hosted.md
@@ -25,7 +25,7 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t
| Guarantee | Feature | Pinned by |
| --- | --- | --- |
-| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. Hosted's own counts are daily totals that name no one, and only the admin reads them. Billing takes no price or owner from the browser, and an open payment dispute suspends a subscription rather than ending it. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/billing-routes.test.ts` |
+| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. Hosted's own counts are daily totals that name no one, and only the admin reads them. Billing takes no price or owner from the browser, records the terms and renewal you agreed to before you pay, and an open payment dispute suspends a subscription rather than ending it. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/billing-routes.test.ts` |
- **FAIL IF** `S83` the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter.
- **FAIL IF** `S84` an unused provider credential enables login, an unknown provider name is accepted, or incomplete enabled credentials silently degrade; inspect `providerBindings` in `hosted/server/policy.ts`.
@@ -34,6 +34,8 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t
- **FAIL IF** `S283` a billing route takes a Stripe Price, customer, owner, quantity, or success or return URL from the request, acts for any owner but the cookie's login, passes Stripe an email the login has not verified, or sends a `ref` or counts a label `checkoutRef` did not produce; inspect `billingRoutes` in `hosted/server/billing-routes.ts`.
- **FAIL IF** `S284` the webhook writes before `Stripe-Signature` verifies over the raw body, reads a body past `WEBHOOK_BODY_BYTES`, answers 2xx before `webhook()` has committed, accepts an event of the other mode, another API version, or a Connect account, counts a metric outside its transaction, so a redelivery counts twice, or lets a failed count fail or roll back the event; inspect `processEvent` in `hosted/server/billing-routes.ts`.
- **FAIL IF** `S285` a partial refund or an open dispute ends a subscription, an open dispute leaves its account entitled, a dispute's suspension follows the event's status rather than the one Stripe returns, a full refund or a lost dispute leaves the subscription its charge paid for current once a delivery answered 2xx, or ends any other subscription, the owner's current one included; inspect `processEvent`, `disputeAction`, `subscriptionPaidBy`, and `endForRefund` in `hosted/server/billing-routes.ts`.
+- **FAIL IF** `S286` checkout answers a Stripe URL without `autoRenew: true` and `terms` equal to `TERMS_VERSION`, for a checkout whose Price `publishedPrice` does not read as the request's `price`, or before its `dormouse_checkout_consents` row, naming the checkout, that version, the login, and the plan and that Price, has committed; or that table cascades from or is deleted with the account. Inspect `billingRoutes` in `hosted/server/billing-routes.ts` and `hosted/server/dormouse-migrations/016_checkout_consents.sql`.
+- **FAIL IF** `S287` a `dormouse_founders` row is inserted or renamed for an account `subscribedSql` does not hold for on the founding Prices, or for any but the cookie's login, or its name passes without `isFounderName` in the route and the table's CHECK refusing a Cc, Cf, Zl, or Zp character, padding, nothing, or more than 64 code points. Inspect the founder route in `hosted/server/billing-routes.ts`, `subscribedSql` in `hosted/server/entitlement.ts`, `isFounderName` in `hosted/server/policy-constants.ts`, and `hosted/server/dormouse-migrations/015_founders.sql`.
- **FAIL IF** `S279` a `dormouse_metrics_daily` row can hold anything but a UTC day, an event, a label on that event's `METRIC_LABELS` list, and a count; or `GET /api/admin/metrics` answers anyone but the verified `ADMIN_EMAIL` (401 without a login, 404 to any other), skips the cookie read gate, or answers more than those rows. Inspect `hosted/server/dormouse-migrations/013_metrics.sql`, `countMetric` in `hosted/server/metrics.ts`, `metricLabel` in `hosted/server/metric-labels.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, and `adminSql` in `hosted/server/entitlement.ts`.
- **FAIL IF** `S86` Hosted account login mints a Burrow ACL grant or substitutes for the existing encrypted pairing/presence proof. The one-time rendezvous carries only handshake ciphertext and authorizes nothing; the ends' handshake and the laptop's confirmation do.
diff --git a/hosted/README.md b/hosted/README.md
index 18a3e1f35..520aa52e9 100644
--- a/hosted/README.md
+++ b/hosted/README.md
@@ -204,7 +204,7 @@ rm vapid.json
The public half is public (`GET /api/push/config` serves it); the private half signs every push. Both are required for a production deploy: preflight refuses a relay Worker missing either. Cloudflare exposes a secret's name, never its value, so preflight cannot tell whether the two match; the relay answers push off for a pair that does not, and the release's live verification fails unless `/api/push/config` answers the key. Rotating the pair makes every phone's subscription stale until Pocket re-registers it, so rotate only on compromise. Previews derive their own pair and never need this one.
-Billing stays closed, and its Cron Trigger idle, until the account Worker holds all five Stripe bindings: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` in `wrangler.jsonc` (`docs/specs/hosted.md` -> "Billing"). Set none of them before checkout opens. `db:migrate` grants `dormouse_app` the `pgstencil_billing` schema itself (`hosted/server/runtime-roles.sql`), since that schema is the package's and no hand grant can precede it.
+Billing stays closed, and its Cron Trigger idle, until the account Worker holds all five Stripe bindings: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` in `wrangler.jsonc` (`docs/specs/hosted.md` -> "Billing"). Set none of them before checkout opens. `db:migrate` grants `dormouse_app` the `pgstencil_billing` schema itself (`hosted/server/runtime-roles.sql`), since that schema is the package's and no hand grant can precede it. Before opening checkout, turn on Stripe's customer emails for successful payments: the `/billing` confirmation tells a buyer Stripe sends each receipt.
Configure the sender and enable each ready provider in `OAUTH_PROVIDERS` in `wrangler.jsonc`, comma separated, reviewed in a PR. The checked-in file enables GitHub, Google, Microsoft, and Apple; `docs/specs/hosted.md` -> "Identity and login" owns what a name and a credential pair do and do not enable. Facebook is outside this milestone.
diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts
index 8acbf4613..382ca099b 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, PRICE_CHANGED, TERMS_VERSION, isFounderName } from "./policy-constants";
+
+export { CHECKOUT_CLOSED, PRICE_CHANGED };
/** 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,15 @@ 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; price?: 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) => {
+ return billed(c, async (billing, setup) => {
+ // `price` is what the page showed beside the renewal the buyer agreed to.
+ if (typeof body?.terms !== "string" || body.autoRenew !== true || !Number.isSafeInteger(body.price))
+ 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 +149,28 @@ 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 });
+ // The Price this checkout charges, a cohort sold out since the page
+ // loaded or one left open at an earlier step included: one the buyer
+ // was not shown is expired, so the page asks again at the new price.
+ const opened = await billing.db
+ .selectFrom("checkouts")
+ .select("price_id")
+ .where("id", "=", id)
+ .where("owner_id", "=", userId)
+ .executeTakeFirstOrThrow();
+ if (publishedPrice(setup, opened.price_id) !== body.price) {
+ await billing.cancelCheckout(userId);
+ return c.json({ message: PRICE_CHANGED }, 409);
+ }
+ // Recorded before the URL is answered, for the Price the buyer was shown.
+ await billing.db.executeQuery(
+ CompiledQuery.raw(
+ `INSERT INTO public.dormouse_checkout_consents ("checkoutId", "termsVersion", "userId", plan, "priceId")
+ VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING`,
+ [id, TERMS_VERSION, userId, plan, opened.price_id],
+ ),
+ );
recordMetric(c, host(c).databaseUrl, "checkout.started", `${plan}:${ref}`);
return c.json({ url });
});
@@ -142,6 +191,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..fd26d2e67 100644
--- a/hosted/server/policy-constants.ts
+++ b/hosted/server/policy-constants.ts
@@ -38,6 +38,26 @@ 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.";
+
+// Checkout's answer when the price the page showed is no longer the one it
+// would charge: the page reloads the price and asks for agreement again.
+export const PRICE_CHANGED = "The price has changed. Check the new price and agree again.";
+
// 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..7919048ca 100644
--- a/hosted/server/tests/billing-routes.test.ts
+++ b/hosted/server/tests/billing-routes.test.ts
@@ -3,18 +3,23 @@ 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, LIST_MONTHLY } from "../../../website/src/lib/hosted-pricing";
import type { BillingSetup } from "../billing";
import {
BILLING_WEBHOOK_PATH,
CHECKOUT_CLOSED,
+ CONSENT_REQUIRED,
ENDED_FOR_REFUND,
+ FOUNDERS_ONLY,
+ PRICE_CHANGED,
+ 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 +28,13 @@ 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, beside `plan` at the price it showed. */
+const consent = (plan: string) => ({
+ plan,
+ terms: TERMS_VERSION,
+ autoRenew: true,
+ price: plan === "monthly" ? LIST_MONTHLY : plan === "yearly" ? LIST_ANNUAL : FOUNDING_LADDER[0],
+});
async function fixture({ closed = false } = {}) {
const context = await createTestContext({ migrations, now: new Date().toISOString() });
@@ -95,11 +107,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: { ...consent(plan), ref } });
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 +170,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 }]);
@@ -190,9 +203,10 @@ test("checkout takes a plan and a ref, never a Price, customer, quantity, or ret
const first = await member.request("/api/billing/checkout", {
method: "POST",
body: {
- plan: "monthly",
+ ...consent("monthly"),
ref: "settings-voice",
- price: "price_x",
+ priceId: "price_x",
+ line_items: [{ price: "price_x", quantity: 3 }],
customer: "cus_x",
quantity: 3,
success_url: "https://attacker.example/",
@@ -226,12 +240,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 +261,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: consent("yearly") })).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 +276,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: consent("monthly") })).status).toBe(200);
expect(f.dev.requests.filter((r) => r.path === "/v1/customers").at(-1)!.body).toEqual({
"metadata[pgstencil_owner]": unverified.id,
});
@@ -276,6 +293,174 @@ 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"`,
+ );
+ const { price: _price, ...unpriced } = consent("yearly");
+ for (const body of [
+ { plan: "yearly" },
+ { plan: "yearly", terms: TERMS_VERSION, price: LIST_ANNUAL },
+ { ...consent("yearly"), autoRenew: "yes" },
+ { ...consent("yearly"), terms: undefined },
+ unpriced,
+ { ...consent("yearly"), price: "100" },
+ ]) {
+ const refused = await member.request("/api/billing/checkout", { method: "POST", body });
+ expect([refused.status, await refused.json()], JSON.stringify(body)).toEqual([400, { message: CONSENT_REQUIRED }]);
+ }
+ const stale = await member.request("/api/billing/checkout", {
+ method: "POST",
+ body: { ...consent("yearly"), terms: "2020-01-01" },
+ });
+ 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: consent("monthly") })).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("checkout refuses a price the page did not show, expiring what it opened, so the consent records the one it did", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const consents = () => f.sql<{ priceId: string }>(`SELECT "priceId" FROM dormouse_checkout_consents ORDER BY "consentedAt"`);
+ const start = (who: Awaited>, plan: string, price: number) =>
+ who.request("/api/billing/checkout", { method: "POST", body: { ...consent(plan), price } });
+ const statuses = () => [...f.dev.checkouts.values()].map((session) => session.status);
+ const member = await f.account();
+ for (const [plan, price] of [["founding", FOUNDING_LADDER[1]!], ["yearly", LIST_MONTHLY]] as const) {
+ const changed = await start(member, plan, price);
+ expect([changed.status, await changed.json()], plan).toEqual([409, { message: PRICE_CHANGED }]);
+ }
+ expect(statuses()).toEqual(["expired", "expired"]);
+
+ // A founding checkout opens at the first step, then the cohort sells out.
+ const late = await f.account();
+ expect((await start(late, "founding", FOUNDING_LADDER[0]!)).status).toBe(200);
+ await f.sql(
+ `WITH owners AS (
+ INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at)
+ SELECT 'filler-' || n, 'filler@example.test', now() FROM generate_series(1, $1::int) n
+ RETURNING owner_id)
+ INSERT INTO pgstencil_billing.subscriptions
+ (id, owner_id, price_id, status, started_at, period_end, cancel_at_period_end, updated_at)
+ SELECT 'sub_' || owner_id, owner_id, $2, 'active', now() - interval '40 days',
+ now() + interval '325 days', false, now() FROM owners`,
+ [FOUNDING_COHORT_SIZE, f.setup.founding[0]],
+ );
+ // A page still showing the sold-out step is refused.
+ expect((await start(member, "founding", FOUNDING_LADDER[0]!)).status).toBe(409);
+ // The reloaded page shows the next step, but the checkout left open would charge the first: it is expired.
+ expect((await start(late, "founding", FOUNDING_LADDER[1]!)).status).toBe(409);
+ expect(statuses().slice(-2)).toEqual(["expired", "expired"]);
+ expect(await consents()).toEqual([{ priceId: f.setup.founding[0] }]);
+ // Asked again, it opens at the price shown, and its consent says so.
+ expect((await start(late, "founding", FOUNDING_LADDER[1]!)).status).toBe(200);
+ expect(await consents()).toEqual([{ priceId: f.setup.founding[0] }, { priceId: f.setup.founding[1] }]);
+ expect(f.dev.requests.filter((r) => r.path === "/v1/checkout/sessions").at(-1)!.body["line_items[0][price]"]).toBe(
+ f.setup.founding[1],
+ );
+});
+
+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. */
diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx
index a59da41e8..9c72f5e4c 100644
--- a/hosted/src/App.test.tsx
+++ b/hosted/src/App.test.tsx
@@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import * as api from "./api";
import type { Enrollment } from "./enrollment";
-import { ENROLLMENT_POLL_INTERVAL_S, TERMS_VERSION } from "../server/policy-constants";
+import type { BillingSummary, CheckoutIntent } from "./billing";
+import { ENROLLMENT_POLL_INTERVAL_S, PRICE_CHANGED, TERMS_VERSION } from "../server/policy-constants";
import { NOT_ENTITLED_ERROR } from "../../remote-lib-common/src/remote/wire.ts";
vi.mock("./api", async (original) => ({
@@ -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,190 @@ 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", 10, "hosted-no-plan");
+ });
+
+ it("reads the new price when the cohort sold out meanwhile, and asks for agreement again", async () => {
+ vi.mocked(api.getSession).mockResolvedValue(login());
+ vi.mocked(api.getBilling).mockResolvedValue(NO_PLAN);
+ vi.mocked(api.startCheckout).mockRejectedValueOnce(new api.PriceChangedError(PRICE_CHANGED));
+ await mount(null, { checkout: { plan: "founding", ref: null } });
+ await tick("agree-terms");
+ await tick("agree-renewal");
+ vi.mocked(api.getBilling).mockResolvedValue({ ...NO_PLAN, founding: { cohort: 1, seatsLeft: 100 } });
+ await click("Continue to payment");
+ expect(api.startCheckout).toHaveBeenLastCalledWith("founding", 50, null);
+ expect(container.querySelector("[role=alert]")?.textContent).toBe(PRICE_CHANGED);
+ expect(text()).toContain("renews automatically every year at $60");
+ for (const box of ["agree-terms", "agree-renewal"])
+ expect(container.querySelector(`#${box}`)!.checked, box).toBe(false);
+ await tick("agree-terms");
+ await tick("agree-renewal");
+ await click("Continue to payment");
+ expect(api.startCheckout).toHaveBeenLastCalledWith("founding", 60, null);
+ });
+
+ 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, "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);
+ 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("reads a 409 PRICE_CHANGED as a price change, and any other 409 as its message", async () => {
+ const { startCheckout, PriceChangedError } = await vi.importActual("./api");
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ message: PRICE_CHANGED }, { status: 409 }));
+ await expect(startCheckout("founding", 50, null)).rejects.toBeInstanceOf(PriceChangedError);
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ message: "Founding is closed." }, { status: 409 }));
+ const closed = startCheckout("founding", 50, null);
+ await expect(closed).rejects.toThrow("Founding is closed.");
+ await expect(closed).rejects.not.toBeInstanceOf(PriceChangedError);
+ });
+
+ 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", 100, "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, price: 100,
+ });
+ });
+});
diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx
index 5a4b40db8..c79393f38 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,14 @@ import {
getProviders,
getSession,
NoPlanError,
+ openPortal,
post,
+ PriceChangedError,
removeComputer,
removePasskey,
+ setFounder,
social,
+ startCheckout,
turnOffHostedVoice,
type Account,
type Computer,
@@ -34,7 +40,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 +92,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 +122,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 +178,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 +202,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 +217,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 +270,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 +382,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
setHostedVoice(null);
setComputers(null);
setPasskeys(null);
+ setBilling(null);
setRefusedPlan(false);
setEnterCode(false);
setCode("");
@@ -385,6 +441,33 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
setError(keepRead);
history.replaceState(null, "", session ? "/account" : "/login");
};
+ const purchase = (plan: Plan, price: number) =>
+ act("checkout", async () => {
+ try {
+ await startCheckout(plan, price, checkout?.ref ?? null);
+ } catch (error) {
+ if (!(error instanceof PriceChangedError)) throw error;
+ // Read the price checkout would charge now, and ask again.
+ await refresh();
+ setNotice({ text: error.message, error: true });
+ return;
+ }
+ // 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 +488,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
};
return `$${price}/${interval}`;
+
+/**
+ * What the buyer agrees to beside the purchase action
+ * (docs/specs/pricing.md -> "Checkout and entitlement"): 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;
+ /** With the price shown beside the renewal the buyer agreed to. */
+ onPurchase: (plan: Plan, price: number) => 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, offer.price);
+ }
+ return (
+
+ );
+}
diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx
index 1eaf9572a..d0fd09fc1 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, price: number) => 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 -> "Checkout and entitlement"):
+ * 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..73ba1655f 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, PRICE_CHANGED, 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,62 @@ 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;
+}
+/** A checkout refused because `price` is no longer what it would charge. */
+export class PriceChangedError extends Error {}
+/**
+ * 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, price: number, ref: string | null) {
+ const response = await billing(
+ "/checkout",
+ sendJson("POST", { plan, ...(ref ? { ref } : {}), terms: TERMS_VERSION, autoRenew: true, price }),
+ );
+ 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. */
+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();
}
diff --git a/hosted/src/stories/Account.stories.tsx b/hosted/src/stories/Account.stories.tsx
index fea5ee4c1..a2f66229b 100644
--- a/hosted/src/stories/Account.stories.tsx
+++ b/hosted/src/stories/Account.stories.tsx
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn } from "storybook/test";
import { providers } from "../api";
-import { hostedMeta, SESSION, ACCOUNT, COMPUTERS, PASSKEYS } from "./fixtures";
+import { hostedMeta, SESSION, ACCOUNT, COMPUTERS, PASSKEYS, NO_PLAN, FOUNDING } from "./fixtures";
import { settledFrame } from "./canvas";
const meta = {
@@ -52,3 +52,20 @@ export const RemoveActions: Story = {
canvas.document.defaultView!.scrollTo(0, 0);
},
};
+export const PlanNone: Story = { args: { ...ACCOUNT, billing: NO_PLAN } };
+export const PlanFounding: Story = { args: { ...ACCOUNT, billing: FOUNDING } };
+export const PlanFounderShown: Story = { args: { ...ACCOUNT, billing: { ...FOUNDING, founder: { offered: true, name: "Alex" } } } };
+export const PlanEnding: Story = { args: { ...ACCOUNT, billing: { ...FOUNDING, renews: false } } };
+/** A failed renewal keeps Manage billing, so the card can be fixed. */
+export const PlanPaymentFailed: Story = {
+ args: { ...ACCOUNT, billing: { ...FOUNDING, active: false, entitled: false, founder: { offered: false, name: null } } },
+};
+/** 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 } } };
+/** 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 } };
diff --git a/hosted/src/stories/Billing.stories.tsx b/hosted/src/stories/Billing.stories.tsx
new file mode 100644
index 000000000..f5b448229
--- /dev/null
+++ b/hosted/src/stories/Billing.stories.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { expect, fn } from "storybook/test";
+import { hostedMeta, SESSION, NO_PLAN, FOUNDING } from "./fixtures";
+import { settledFrame } from "./canvas";
+
+const meta = {
+ ...hostedMeta, title: "Hosted/Billing",
+ args: { view: "billing", session: SESSION, billing: FOUNDING },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+/** Back from Stripe: the durable confirmation and the founders opt-in, unticked. */
+export const Confirmed: Story = { args: { confirmed: true } };
+export const Returning: Story = {};
+export const Phone: Story = { args: { confirmed: true, phone: true } };
+export const Light: Story = { args: { confirmed: true, light: true } };
+export const Monthly: Story = {
+ args: { billing: { ...FOUNDING, plan: "monthly", price: 10, until: "2026-11-21T12:00:00Z", founder: { offered: false, name: null } } },
+};
+export const Ending: Story = { args: { billing: { ...FOUNDING, renews: false } } };
+export const FounderShown: Story = { args: { billing: { ...FOUNDING, founder: { offered: true, name: "Alex" } } } };
+export const NoPlan: Story = { args: { billing: NO_PLAN } };
+export const ConfirmFailed: Story = { args: { billing: NO_PLAN, error: { read: false, message: "Checkout not found." } } };
+export const Closed: Story = { args: { billing: "closed" } };
+export const SavingFounder: Story = { args: { billing: { ...FOUNDING, founder: { offered: true, name: "Alex" } }, busy: "founder" } };
+export const JoinFounders: Story = {
+ args: { confirmed: true, saveFounder: fn() },
+ play: async ({ canvasElement, args }) => {
+ const canvas = await settledFrame(canvasElement);
+ const shown = canvas.getByLabelText("Show me in the founders row on the Hosted page");
+ await expect(shown).not.toBeChecked();
+ await canvas.user.click(shown);
+ const name = canvas.getByLabelText("Name to show");
+ await expect(name).toHaveValue("Alex");
+ await canvas.user.clear(name);
+ await expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled();
+ await canvas.user.type(name, "Alex D.");
+ await canvas.user.click(canvas.getByRole("button", { name: "Save" }));
+ await expect(args.saveFounder).toHaveBeenCalledWith(true, "Alex D.");
+ canvas.document.defaultView!.scrollTo(0, 0);
+ },
+};
diff --git a/hosted/src/stories/Checkout.stories.tsx b/hosted/src/stories/Checkout.stories.tsx
new file mode 100644
index 000000000..45fc64b59
--- /dev/null
+++ b/hosted/src/stories/Checkout.stories.tsx
@@ -0,0 +1,43 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { expect, fn } from "storybook/test";
+import { hostedMeta, SESSION, NO_PLAN, FOUNDING } from "./fixtures";
+import { settledFrame } from "./canvas";
+
+const meta = {
+ ...hostedMeta, title: "Hosted/Checkout",
+ args: { view: "checkout", session: SESSION, billing: NO_PLAN },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Founding: Story = {};
+export const Yearly: Story = { args: { checkoutPlan: "yearly" } };
+export const Monthly: Story = { args: { checkoutPlan: "monthly" } };
+/** Past the ladder's last step, founding is off sale. */
+export const FoundingClosed: Story = { args: { billing: { ...NO_PLAN, founding: null } } };
+export const Phone: Story = { args: { phone: true } };
+export const Light: Story = { args: { light: true } };
+export const SignedOut: Story = { args: { session: null, billing: null } };
+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 = {
+ args: { purchase: fn() },
+ play: async ({ canvasElement, args }) => {
+ const canvas = await settledFrame(canvasElement);
+ const pay = canvas.getByRole("button", { name: "Continue to payment" });
+ await expect(pay).toBeDisabled();
+ await canvas.user.click(canvas.getByLabelText(/I agree to the/));
+ await expect(pay).toBeDisabled();
+ await canvas.user.click(canvas.getByLabelText(/renews automatically/));
+ await canvas.user.click(pay);
+ await expect(args.purchase).toHaveBeenCalledWith("founding", 50);
+ canvas.document.defaultView!.scrollTo(0, 0);
+ },
+};
diff --git a/hosted/src/stories/Enrollment.stories.tsx b/hosted/src/stories/Enrollment.stories.tsx
index ded12bceb..3ef6b65cf 100644
--- a/hosted/src/stories/Enrollment.stories.tsx
+++ b/hosted/src/stories/Enrollment.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn } from "storybook/test";
-import { hostedMeta, SESSION, ACCOUNT, ENROLLMENT } from "./fixtures";
+import { hostedMeta, SESSION, ACCOUNT, ENROLLMENT, NO_PLAN } from "./fixtures";
import { settledFrame } from "./canvas";
const meta = {
@@ -23,6 +23,14 @@ export const NoPlan: Story = {
},
};
export const NoPlanVoice: Story = { args: { ...Voice.args, noPlan: true } };
+/** Billing open: the plans link opens checkout on this origin, in another tab. */
+export const NoPlanCheckoutOpen: Story = {
+ args: { noPlan: true, billing: NO_PLAN },
+ play: async ({ canvasElement }) => {
+ const canvas = await settledFrame(canvasElement);
+ await expect(canvas.getByRole("link", { name: "See Hosted plans" })).toHaveAttribute("href", "/checkout?ref=hosted-no-plan");
+ },
+};
export const InvalidLink: Story = { args: { enrolling: { code: null } } };
export const InvalidLinkSignedOut: Story = { args: { session: null, enrolling: { code: null } } };
export const Approving: Story = { args: { busy: "approve" } };
diff --git a/hosted/src/stories/fixtures.tsx b/hosted/src/stories/fixtures.tsx
index 5c76eeb98..c1fc242cf 100644
--- a/hosted/src/stories/fixtures.tsx
+++ b/hosted/src/stories/fixtures.tsx
@@ -3,6 +3,8 @@ import { HostedPage, type HostedPageProps } from "../HostedPage";
import { HostedFrame } from "./HostedFrame";
import { settledFrame } from "./canvas";
import { providers, type Computer, type Passkey, type Session } from "../api";
+import type { BillingSummary } from "../billing";
+import { TERMS_VERSION } from "../../server/policy-constants";
export const SESSION: Session = {
user: { id: "account-studio", name: "Alex", email: "alex@example.com" },
@@ -19,6 +21,17 @@ export const PASSKEYS: Passkey[] = [
];
export const ENROLLMENT = { code: "7KQM-X4TD", purpose: "remote" as const };
export const ACCOUNT = { session: SESSION, computers: COMPUTERS, passkeys: PASSKEYS };
+/** Billing open, no plan held, the first founding cohort on sale. */
+export const NO_PLAN: BillingSummary = {
+ plan: null, price: null, active: false, until: null, renews: false, entitled: false, suspended: false,
+ founding: { cohort: 0, seatsLeft: 63 }, founder: { offered: false, name: null }, consent: null,
+};
+/** A founding plan bought today, renewing next year. */
+export 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" },
+};
export type HostedStoryArgs = Partial & { phone?: boolean; light?: boolean };
const idle = async () => {};
const nothing = () => {};
diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json
index a6849ea2a..e51615e29 100644
--- a/scripts/security-rule-ids.json
+++ b/scripts/security-rule-ids.json
@@ -1,5 +1,5 @@
{
- "next": 286,
+ "next": 288,
"retired": [
265
]
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index 64ec9eeaa..8776459ad 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -12,13 +12,13 @@
"docs/specs/dor-tools-builtin.md": 1750,
"docs/specs/dor-tools-lib.md": 350,
"docs/specs/glossary.md": 2950,
- "docs/specs/hosted.md": 5450,
+ "docs/specs/hosted.md": 5650,
"docs/specs/layout.md": 7250,
"docs/specs/mobile-terminal-ui.md": 1250,
"docs/specs/mouse-and-clipboard.md": 4200,
"docs/specs/one-time.md": 2650,
"docs/specs/pocket-app.md": 3650,
- "docs/specs/pricing.md": 2500,
+ "docs/specs/pricing.md": 2600,
"docs/specs/relay.md": 5700,
"docs/specs/remote-api.md": 4450,
"docs/specs/remote-network.md": 2400,
@@ -26,7 +26,7 @@
"docs/specs/reopen.md": 1200,
"docs/specs/security-audit.md": 5400,
"docs/specs/security-ci.md": 3400,
- "docs/specs/security-hosted.md": 3550,
+ "docs/specs/security-hosted.md": 3700,
"docs/specs/security-local.md": 6400,
"docs/specs/security-remote.md": 7200,
"docs/specs/security-supply-chain.md": 1650,
diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx
index bf5635967..f620f177b 100644
--- a/website/src/pages/Privacy.tsx
+++ b/website/src/pages/Privacy.tsx
@@ -31,7 +31,8 @@ export const PRIVACY_SECTIONS: PolicySection[] = [
{ id: "subscription", title: "Your subscription", body: <>
You buy Hosted subscriptions from DiffPlug LLC. We use Stripe Billing, Stripe-hosted Checkout, and Stripe’s customer portal to process payments and manage subscriptions. Stripe collects payment details, billing addresses, and applicable tax information. It processes information on our behalf and for its own purposes, such as fraud prevention and legal compliance, as described in Stripe’s privacy policy. Dormouse does not receive or store your full card number.
We receive subscription and billing information from Stripe: customer and subscription identifiers linked to your account, the billing email, the plan and price (including a founding cohort, if any), status, billing-period dates, cancellation status, billing-event records, whether a payment dispute is open, and which Dormouse link led to a purchase (such as the voice setting), which also travels to Stripe with the checkout. Stripe’s dashboard and billing records may also make your billing address, tax information, invoices, and limited payment-method details, such as card brand and last four digits, available to us for support, accounting, refunds, and disputes.
-
If you buy a founding plan and tick the box to appear in the founders row, we show your account name and profile picture on the Hosted page. The box starts unticked, and you can untick it at any time. We serve those pictures from our own site, so a visitor’s browser never asks your sign-in provider for them. Everyone else counts only toward the total.
+
When you start a checkout, we record which plan and price it offered, which version of the terms you agreed to, that you agreed to automatic renewal, and when.
+
If you buy a founding plan and tick the box to appear in the founders row, we show the name you choose there and your profile picture on the Hosted page. The box starts unticked, and you can untick it at any time. We serve those pictures from our own site, so a visitor’s browser never asks your sign-in provider for them. Everyone else counts only toward the total.
After checkout we may ask four optional questions about price. Nothing is sent unless you answer, and we use the answers only to set future prices.
We operate from the United States and use providers that may process information in other countries. The production database is hosted in the United States.
We retain account information while you maintain an account. Account-linked daily voice counters currently remain until account deletion. Sign-in codes, browser logins, Relay sessions, and pairing challenges are short-lived; expiry does not mean every related database or delivery-log record is immediately erased. Removing a computer from your account deletes its Relay records and push subscriptions. A Natural Voice credential is deleted when you turn Natural Voice off on that computer or from your account page, or after a year without use. Our encrypted deployment-backup artifacts in GitHub are configured to expire after 30 days; providers’ own backups follow their retention arrangements.
Support and security records are retained for the time needed to resolve the request or incident and any related dispute, or to meet a legal preservation obligation. We assess the record’s purpose, whether the issue remains open, and applicable legal deadlines. Deleting an account does not immediately remove copies in backups or records we must keep for these purposes.
-
We keep subscription and billing-event records after a subscription ends, and after account deletion, for as long as accounting, tax, and dispute obligations require. Stripe keeps its own payment records under its policy.
+
We keep subscription and billing-event records, including what you agreed to at checkout, after a subscription ends, and after account deletion, for as long as accounting, tax, and dispute obligations require. Stripe keeps its own payment records under its policy.
We protect information with encrypted connections, access controls, and encrypted backup artifacts. No service can guarantee absolute security. Our security documentation describes the current boundaries and limitations.