From 6d82aab2c90da31f1298268224652774141e034d Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 05:51:51 -0700
Subject: [PATCH 1/5] Hosted: online EEA/UK withdrawal and account closure
POST /api/billing/withdraw records a withdrawal from the held subscription
(dormouse_withdrawals, migration 017) before answering, emails the durable
acknowledgement to the Stripe billing email, ends the subscription at once,
and refunds every paid invoice payment in full; the cron retries either step.
GET /api/billing offers it within three weeks of the start to an EEA, UK, or
unknown billing country, and shows the latest withdrawal.
POST /api/account/close needs a recent login, cancels every current
subscription in Stripe, then records the closure and deletes the account in
one statement; billing closed, it refuses an account still holding one.
Co-Authored-By: Claude Opus 5.5
---
hosted/server/account-app.ts | 16 +-
hosted/server/billing-routes.ts | 55 +++-
hosted/server/billing.ts | 18 +-
hosted/server/closure.ts | 106 +++++++
hosted/server/dev.ts | 6 +-
.../017_withdrawals_and_closures.sql | 35 +++
hosted/server/entitlement.ts | 3 +
hosted/server/preview-worker.ts | 1 +
hosted/server/tests/billing-routes.test.ts | 235 +++++++++++++-
hosted/server/tests/billing.test.ts | 8 +-
hosted/server/tests/migrations.test.ts | 2 +
hosted/server/tests/stripe-fake.ts | 63 +++-
hosted/server/withdrawal.ts | 287 ++++++++++++++++++
website/src/lib/hosted-withdrawal.ts | 13 +
14 files changed, 818 insertions(+), 30 deletions(-)
create mode 100644 hosted/server/closure.ts
create mode 100644 hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql
create mode 100644 hosted/server/withdrawal.ts
create mode 100644 website/src/lib/hosted-withdrawal.ts
diff --git a/hosted/server/account-app.ts b/hosted/server/account-app.ts
index 00782a1ff..c80229a2f 100644
--- a/hosted/server/account-app.ts
+++ b/hosted/server/account-app.ts
@@ -1,8 +1,11 @@
import type { Context, ExecutionContext, Hono } from "hono";
+import { postmarkEmail } from "@pgstencil/auth/postmark";
+import type { EmailSender } from "pgstencil";
import { cookieAdmin, openAuthRead, ownPageRead } from "./account-gate";
import { billingSetup, resyncBilling } from "./billing";
import { billingRoutes, type BillingHost } from "./billing-routes";
import type { AccountEnv } from "./bindings";
+import { closureRoutes, type ClosureHost } from "./closure";
import { accountRules } from "./headers";
import { ADMIN_METRICS_PATH } from "./metric-labels";
import { readMetrics } from "./metrics";
@@ -15,8 +18,9 @@ import { workerApp } from "./worker-app";
/**
* The account Worker (`hosted.dormouse.sh`): auth, providers, readiness,
* terms acceptance, Natural Voice on or off, the Relay's account routes, the
- * admin's metrics, the frontend, and billing. The production and preview entries differ only in `fetchAuth`'s
- * mail and in `bindings`.
+ * admin's metrics, the frontend, billing, and account closure. The
+ * production and preview entries differ only in their mail (`fetchAuth`'s and
+ * `mail`) and in `bindings`.
*/
export function accountApp(
fetchAuth: (
@@ -26,6 +30,8 @@ export function accountApp(
) => Response | Promise,
bindings: (env: AccountEnv) => AccountEnv,
configure?: (app: Hono<{ Bindings: AccountEnv }>) => void,
+ /** The mail billing sends, a withdrawal's acknowledgement: Postmark unless a preview's inbox. */
+ mail: (env: AccountEnv) => EmailSender = (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM),
) {
return workerApp({
bindings,
@@ -47,17 +53,19 @@ export function accountApp(
headers.delete("cookie");
return fetchAuth(new Request(c.req.raw, { headers }), c.env, c.executionCtx);
});
- const host = (c: Context<{ Bindings: AccountEnv }>): RelayAccountHost & BillingHost => ({
+ const host = (c: Context<{ Bindings: AccountEnv }>): RelayAccountHost & BillingHost & ClosureHost => ({
databaseUrl: c.env.HYPERDRIVE.connectionString,
auth: (request) => fetchAuth(request, c.env, c.executionCtx),
approveLimit: c.env.RELAY_APPROVE_LIMIT,
closeBurrow: (userId, burrowId) => relayRoom(c.env.RELAY_ROOM, userId).closeBurrow(burrowId),
setup: () => billingSetup(c.env),
+ mail: () => mail(c.env),
});
termsRoutes(app, host);
voiceAccountRoutes(app, host);
relayAccountRoutes(app, host);
billingRoutes(app, host);
+ closureRoutes(app, host);
app.get(ADMIN_METRICS_PATH, cookieAdmin(host), async (c) =>
c.json(await readMetrics(c.env.HYPERDRIVE.connectionString)),
);
@@ -66,6 +74,6 @@ export function accountApp(
// (`ACCOUNT_PAGE_PATH`), which this SPA fallback answers.
fallback: (app) => app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw)),
scheduled: (_controller, env) =>
- resyncBilling(billingSetup(env), env.HYPERDRIVE.connectionString, env.APP_ORIGIN),
+ resyncBilling(billingSetup(env), env.HYPERDRIVE.connectionString, env.APP_ORIGIN, () => mail(env)),
});
}
diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts
index 382ca099b..73bc340e6 100644
--- a/hosted/server/billing-routes.ts
+++ b/hosted/server/billing-routes.ts
@@ -7,10 +7,13 @@ import { CompiledQuery, type Kysely } from "kysely";
import { readJson } from "remote-lib-common";
import { accountQuery, cookieLogin, type AccountHost } from "./account-gate";
import { REFUND_DAYS, foundingCohort, publishedPrice, withBilling, type BillingSetup, type Plan } from "./billing";
-import { CURRENT_STATUSES, entitledSql, subscribedSql } from "./entitlement";
+import { CURRENT_STATUSES, ENDED_STATUSES, entitledSql, subscribedSql } from "./entitlement";
import { checkoutRef, type MetricEvent } from "./metric-labels";
import { countMetricInTransaction, recordMetric } from "./metrics";
import { BILLING_PLANS, CHECKOUT_CLOSED, PRICE_CHANGED, TERMS_VERSION, isFounderName } from "./policy-constants";
+import { completeWithdrawal, recordWithdrawal, recordedWithdrawal, withdrawalOffer } from "./withdrawal";
+import { SUPPORT_EMAIL } from "../../website/src/lib/hosted-withdrawal";
+import type { EmailSender } from "pgstencil";
export { CHECKOUT_CLOSED, PRICE_CHANGED };
@@ -18,6 +21,8 @@ export { CHECKOUT_CLOSED, PRICE_CHANGED };
export interface BillingHost extends AccountHost {
/** The deployment's billing setup, null while billing is closed; throws when misconfigured. */
setup(): BillingSetup | null;
+ /** The account's mail, which a withdrawal's acknowledgement goes out by. */
+ mail(): EmailSender;
}
/** Checkout's answer to a request that does not carry both agreements. */
@@ -29,6 +34,9 @@ export const TERMS_CHANGED = "The terms have changed. Reload this page to read t
/** 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.";
+/** The withdrawal route's answer for a contract no longer withdrawable online, or never. */
+export const WITHDRAWAL_CLOSED = `This contract can no longer be withdrawn from online. Email ${SUPPORT_EMAIL}.`;
+
/** Stripe's webhook endpoint. */
export const BILLING_WEBHOOK_PATH = "/api/billing/webhook";
@@ -40,8 +48,7 @@ export const ENDED_FOR_REFUND = "dormouse: full refund or final reversal";
const JSON_BODY_BYTES = 4096;
const CHECKOUT_ID = /^[A-Za-z0-9_-]{1,64}$/;
-/** Subscription statuses Stripe has finished with (pgstencil's `missing` included). */
-const ENDED = ["canceled", "incomplete_expired", "missing"];
+const SUBSCRIPTION_ID = /^sub_[A-Za-z0-9_]{1,250}$/;
const isPlan = (plan: unknown): plan is Plan => (BILLING_PLANS as readonly unknown[]).includes(plan);
@@ -92,6 +99,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
founder: boolean;
name: string | null;
consent: { termsVersion: string; consentedAt: string } | null;
+ withdrawn: { subscription: string } | null;
}>(
host(c),
// A held subscription refuses another checkout, so the latest consent is its own.
@@ -101,7 +109,12 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
(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
+ ORDER BY k."consentedAt" DESC LIMIT 1) AS consent,
+ (SELECT json_build_object('reference', w.id, 'subscription', w."subscriptionId", 'plan', w.plan,
+ 'startedAt', w."contractStartedAt", 'submittedAt', w."submittedAt",
+ 'acknowledged', w."acknowledgedAt" IS NOT NULL, 'settled', w."settledAt" IS NOT NULL)
+ FROM dormouse_withdrawals w WHERE w."userId" = u.id
+ ORDER BY w."submittedAt" DESC LIMIT 1) AS withdrawn
FROM "user" u WHERE u.id = $1`,
[userId, setup.founding],
),
@@ -109,7 +122,12 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
]);
// A subscription Stripe still holds, paid up or not: its owner manages it
// in the portal (a failed card included) and cannot check out again.
- const held = !!status.subscription && !ENDED.includes(status.subscription.status);
+ const held = !!status.subscription && !ENDED_STATUSES.includes(status.subscription.status);
+ // A contract withdrawn from once is never offered again.
+ const offer =
+ held && account?.withdrawn?.subscription !== status.subscription!.id
+ ? await withdrawalOffer(billing, userId, status.subscription)
+ : null;
return c.json({
plan: held ? status.plan : null,
price: held ? publishedPrice(setup, status.subscription!.price_id) : null,
@@ -122,6 +140,8 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
// 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,
+ withdrawal: offer && { subscription: offer.subscription, startedAt: offer.startedAt, until: offer.until, email: offer.email },
+ withdrawn: account?.withdrawn ?? null,
});
};
@@ -191,6 +211,31 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
billed(c, async (billing) => c.json({ url: await billing.portal(c.get("login").userId) })),
);
+ // An EEA or UK consumer's withdrawal from the contract it names, which must
+ // be the held subscription the page offers it for. Recorded before the
+ // answer, it stands: an acknowledgement or a settlement that fails after is
+ // logged, and the cron finishes it. Naming a contract already withdrawn
+ // from answers that withdrawal again.
+ app.post("/api/billing/withdraw", limit(JSON_BODY_BYTES), gate, async (c) => {
+ const { userId } = c.get("login");
+ const subscription = (await readJson<{ subscription?: unknown }>(c))?.subscription;
+ if (typeof subscription !== "string" || !SUBSCRIPTION_ID.test(subscription))
+ return c.json({ message: "Choose the contract to withdraw from." }, 400);
+ return billed(c, async (billing, setup) => {
+ let row = await recordedWithdrawal(billing, userId, subscription);
+ if (!row) {
+ const { subscription: held, plan } = await billing.status(userId);
+ const offer = held?.id === subscription && plan ? await withdrawalOffer(billing, userId, held) : null;
+ if (!offer || !held || !plan) return c.json({ message: WITHDRAWAL_CLOSED }, 409);
+ row = await recordWithdrawal(billing, userId, plan, held, offer);
+ }
+ await completeWithdrawal(billing, () => host(c).mail(), row).catch((error: unknown) =>
+ console.error(`Withdrawal ${row.id} is recorded but not complete; the cron retries it`, error),
+ );
+ return summary(c, billing, setup, 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) => {
diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts
index 3c62cc87c..083d36272 100644
--- a/hosted/server/billing.ts
+++ b/hosted/server/billing.ts
@@ -1,10 +1,11 @@
// Rules: docs/specs/hosted.md -> "Billing".
import { Billing, BillingError, Stripe, type BillingDB } from "@pgstencil/stripe";
-import { SecureRandom, SystemTime } from "pgstencil";
+import { SecureRandom, SystemTime, type EmailSender } from "pgstencil";
import { connectDatabase, queryDatabase } from "pgstencil/postgres";
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 { retryWithdrawals } from "./withdrawal";
import { BILLING_PLANS, BILLING_RETURN_PATH } from "./policy-constants";
/** The plans checkout sells, by the names `status()` and a checkout use. */
@@ -166,10 +167,17 @@ const RESYNC_OWNERS = 20;
/**
* The account Worker's Cron Trigger: reconciles up to {@link RESYNC_OWNERS}
* random owners whose `active` or `trialing` row no longer grants access, so
- * a missed renewal webhook costs a member one run at most. Billing closed
- * does nothing. Every owner is tried; any failure fails the invocation after.
+ * a missed renewal webhook costs a member one run at most, then finishes
+ * any withdrawal whose acknowledgement or settlement failed. Billing closed
+ * does nothing. Every owner and withdrawal is tried; any failure fails the
+ * invocation after.
*/
-export async function resyncBilling(setup: BillingSetup | null, databaseUrl: string, origin: string) {
+export async function resyncBilling(
+ setup: BillingSetup | null,
+ databaseUrl: string,
+ origin: string,
+ mail: () => EmailSender,
+) {
if (!setup) return;
const due = await queryDatabase<{ owner: string }>(
databaseUrl,
@@ -178,11 +186,11 @@ export async function resyncBilling(setup: BillingSetup | null, databaseUrl: str
GROUP BY owner_id ORDER BY random() LIMIT $1`,
[RESYNC_OWNERS],
);
- if (!due.length) return;
const failed: unknown[] = [];
await withBilling(setup, databaseUrl, origin, async (billing) => {
for (const { owner } of due)
await billing.reconcile(owner).catch((error: unknown) => failed.push(error));
+ failed.push(...(await retryWithdrawals(billing, mail)));
});
if (failed.length) throw new AggregateError(failed, `${failed.length} billing resyncs failed`);
}
diff --git a/hosted/server/closure.ts b/hosted/server/closure.ts
new file mode 100644
index 000000000..ac8ad22a2
--- /dev/null
+++ b/hosted/server/closure.ts
@@ -0,0 +1,106 @@
+// Rules: docs/specs/hosted.md -> "Account closure".
+import type { Billing } from "@pgstencil/stripe";
+import type { Context, Hono } from "hono";
+import { bodyLimit } from "hono/body-limit";
+import { readJson } from "remote-lib-common";
+import { accountQuery, cookieLogin, type AccountHost } from "./account-gate";
+import { withBilling, type BillingSetup, type Plan } from "./billing";
+import { CURRENT_STATUSES } from "./entitlement";
+import { LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW } from "./policy-constants";
+
+/** What one request's account deployment provides to account closure. */
+export interface ClosureHost extends AccountHost {
+ /** The deployment's billing setup, null while billing is closed. */
+ setup(): BillingSetup | null;
+ /** Close a deleted Burrow's live relay socket (`RelayRoom.closeBurrow`). */
+ closeBurrow(userId: string, burrowId: string): Promise;
+}
+
+/** The 403 a closure from a login older than the recent-login window gets. */
+export const CLOSE_RECENT_LOGIN = `Sign in again to close your account. Closing it needs a login from the last ${RECENT_LOGIN_WINDOW}.`;
+
+/** The 503 a closure gets when it cannot reach billing to cancel a subscription the account holds. */
+export const CLOSE_UNAVAILABLE = "Your account cannot be closed online right now. Try again later, or email support@dormouse.sh.";
+
+/** The cancellation comment a subscription ended by closing its account carries. */
+export const ENDED_FOR_CLOSURE = "dormouse: account closed";
+
+/**
+ * Expires `owner`'s open checkout, then cancels in Stripe at once, without
+ * proration or a final invoice, every subscription of theirs still current;
+ * answers the ones it canceled. An owner billing never knew has none.
+ */
+async function endSubscriptions(billing: Billing, owner: string): Promise {
+ const known = await billing.db.selectFrom("accounts").select("owner_id").where("owner_id", "=", owner).executeTakeFirst();
+ if (!known) return [];
+ // A checkout Stripe completed meanwhile is confirmed here, and so ended below.
+ await billing.cancelCheckout(owner);
+ await billing.reconcile(owner);
+ const current = await billing.db
+ .selectFrom("subscriptions")
+ .select("id")
+ .where("owner_id", "=", owner)
+ .where("status", "in", [...CURRENT_STATUSES])
+ .execute();
+ for (const { id } of current)
+ await billing.stripe.subscriptions.cancel(id, {
+ cancellation_details: { comment: ENDED_FOR_CLOSURE },
+ prorate: false,
+ invoice_now: false,
+ });
+ if (current.length) await billing.reconcile(owner);
+ return current.map(({ id }) => id);
+}
+
+/**
+ * The account's own closure, `POST /api/account/close`: a recent login and
+ * `{ close: true }`, then every current subscription is canceled in Stripe
+ * before the account is deleted, its closure recorded in the same statement.
+ * Billing closed, an account still holding a current subscription is refused
+ * rather than deleted with it. Call before any `/api/*` catch-all.
+ */
+export function closureRoutes(app: Hono, host: (c: Context) => ClosureHost) {
+ const small = bodyLimit({ maxSize: 1024, onError: (c) => c.json({ message: "Request too large." }, 413) });
+
+ app.post("/api/account/close", small, cookieLogin(host), async (c) => {
+ const { userId, createdAt } = c.get("login");
+ // Fails closed, as approval's check does.
+ if (!(Date.now() - Date.parse(String(createdAt)) < LOGIN_FRESH_AGE_MS))
+ return c.json({ message: CLOSE_RECENT_LOGIN }, 403);
+ if ((await readJson<{ close?: unknown }>(c))?.close !== true)
+ return c.json({ message: "Confirm that you want to close your account." }, 400);
+ const deployment = host(c);
+ const setup = deployment.setup();
+ let canceled: string[];
+ if (setup)
+ canceled = await withBilling(setup, deployment.databaseUrl, new URL(c.req.url).origin, (billing) =>
+ endSubscriptions(billing, userId),
+ );
+ else {
+ const [held] = await accountQuery<{ held: boolean }>(
+ deployment,
+ `SELECT EXISTS (SELECT FROM pgstencil_billing.subscriptions WHERE owner_id = $1 AND status = ANY ($2::text[])) AS held`,
+ [userId, CURRENT_STATUSES],
+ );
+ if (held?.held) return c.json({ message: CLOSE_UNAVAILABLE }, 503);
+ canceled = [];
+ }
+ // The account's rows cascade from its user; billing records, consents,
+ // withdrawals, and this closure have no foreign key and stay.
+ const [closed] = await accountQuery<{ burrows: string[] | null }>(
+ deployment,
+ `WITH burrows AS (SELECT array_agg("burrowId") AS ids FROM dormouse_relay_burrows WHERE "userId" = $1),
+ recorded AS (
+ INSERT INTO dormouse_account_closures ("userId", "canceledSubscriptions") VALUES ($1, $2)
+ ),
+ deleted AS (DELETE FROM "user" WHERE id = $1)
+ SELECT (SELECT ids FROM burrows) AS burrows`,
+ [userId, canceled],
+ );
+ for (const burrowId of closed?.burrows ?? [])
+ await deployment.closeBurrow(userId, burrowId).catch((error: unknown) =>
+ console.error("Closing a closed account's Burrow socket failed; the sweep will", error),
+ );
+ return c.body(null, 204);
+ });
+}
diff --git a/hosted/server/dev.ts b/hosted/server/dev.ts
index c90e6f899..c256e638c 100644
--- a/hosted/server/dev.ts
+++ b/hosted/server/dev.ts
@@ -11,6 +11,7 @@ import { developmentDatabase } from "pgstencil/database";
import { EmailDev, SecureRandom, SystemTime } from "pgstencil";
import { stripeDevBilling } from "./billing-dev";
import { BILLING_WEBHOOK_PATH, billingRoutes, type BillingHost } from "./billing-routes";
+import { closureRoutes, type ClosureHost } from "./closure";
import { authMetricHooks } from "./metrics";
import { authPolicy } from "./policy";
import { migrations } from "./migrations";
@@ -77,7 +78,7 @@ const stripe = await stripeDevBilling(
stripe.dev.setWebhookTarget(origin + BILLING_WEBHOOK_PATH);
const app = new Hono();
// Built once, so the approval limiter counts across requests.
-const host: RelayAccountHost & BillingHost = {
+const host: RelayAccountHost & BillingHost & ClosureHost = {
databaseUrl,
auth: (request: Request) => auth.app.fetch(request),
approveLimit: devLimit(10),
@@ -86,10 +87,13 @@ const host: RelayAccountHost & BillingHost = {
// (`RELAY_ROOM_SWEEP_MS`); its reconnect is refused at once.
closeBurrow: async () => {},
setup: () => stripe.setup,
+ // A withdrawal's acknowledgement lands in the local inbox beside sign-in codes.
+ mail: () => email,
};
voiceAccountRoutes(app, () => host);
relayAccountRoutes(app, () => host);
billingRoutes(app, () => host);
+closureRoutes(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));
diff --git a/hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql b/hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql
new file mode 100644
index 000000000..52869d37a
--- /dev/null
+++ b/hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql
@@ -0,0 +1,35 @@
+-- Up Migration
+-- An EEA or UK consumer's withdrawal from a subscription contract, submitted
+-- online (docs/specs/hosted.md -> "Billing"): one row per contract, the
+-- Stripe subscription it ends, written before the submission is answered.
+-- `acknowledgedAt` is when the emailed acknowledgement went out, and
+-- `settledAt` when the subscription had ended and every payment for it was
+-- refunded; the cron retries either until it is set. No foreign key: like
+-- the checkout consents, it is a billing record kept after the account is
+-- deleted.
+CREATE TABLE dormouse_withdrawals (
+ id text PRIMARY KEY,
+ "userId" text NOT NULL,
+ "subscriptionId" text NOT NULL UNIQUE,
+ plan text NOT NULL,
+ "priceId" text NOT NULL,
+ "contractStartedAt" timestamptz NOT NULL,
+ -- The billing address's country Stripe held, or null when unknown.
+ country text,
+ "submittedAt" timestamptz NOT NULL,
+ "acknowledgedAt" timestamptz,
+ "settledAt" timestamptz
+);
+CREATE INDEX dormouse_withdrawals_user ON dormouse_withdrawals ("userId");
+
+-- An account closed from its own page, and the subscriptions closing it
+-- canceled in Stripe first. No foreign key, for the same reason.
+CREATE TABLE dormouse_account_closures (
+ "userId" text PRIMARY KEY,
+ "canceledSubscriptions" text[] NOT NULL,
+ "closedAt" timestamptz NOT NULL DEFAULT now()
+);
+
+-- Down Migration
+DROP TABLE dormouse_account_closures;
+DROP TABLE dormouse_withdrawals;
diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts
index 0854580a0..6f0871223 100644
--- a/hosted/server/entitlement.ts
+++ b/hosted/server/entitlement.ts
@@ -12,6 +12,9 @@ if (!/^[^'\\]+$/.test(ADMIN_EMAIL)) throw new Error("ADMIN_EMAIL cannot be inlin
* subscription; access needs exactly one of them.
*/
export const CURRENT_STATUSES: readonly string[] = ["trialing", "active", "past_due", "unpaid", "paused", "incomplete"];
+/** Subscription statuses Stripe has finished with (pgstencil's `missing` included). */
+export const ENDED_STATUSES: readonly string[] = ["canceled", "incomplete_expired", "missing"];
+
const CURRENT = CURRENT_STATUSES.map((status) => `'${status}'`).join(", ");
/**
diff --git a/hosted/server/preview-worker.ts b/hosted/server/preview-worker.ts
index aff79d92f..21f40f50b 100644
--- a/hosted/server/preview-worker.ts
+++ b/hosted/server/preview-worker.ts
@@ -32,4 +32,5 @@ export default accountApp(
return mail ? c.html(messagePage(mail)) : c.notFound();
});
},
+ (env) => postgresInbox(env.HYPERDRIVE.connectionString),
);
diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts
index 7919048ca..16b149a12 100644
--- a/hosted/server/tests/billing-routes.test.ts
+++ b/hosted/server/tests/billing-routes.test.ts
@@ -1,10 +1,12 @@
import { test, expect, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { Hono } from "hono";
+import { EmailDev } from "pgstencil";
import { createTestContext } from "pgstencil/testing";
import { queryDatabase } from "pgstencil/postgres";
import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER, LIST_ANNUAL, LIST_MONTHLY } from "../../../website/src/lib/hosted-pricing";
-import type { BillingSetup } from "../billing";
+import { WITHDRAWAL_STATEMENT } from "../../../website/src/lib/hosted-withdrawal";
+import { resyncBilling, type BillingSetup } from "../billing";
import {
BILLING_WEBHOOK_PATH,
CHECKOUT_CLOSED,
@@ -14,10 +16,13 @@ import {
PRICE_CHANGED,
TERMS_CHANGED,
WEBHOOK_BODY_BYTES,
+ WITHDRAWAL_CLOSED,
billingRoutes,
disputeAction,
} from "../billing-routes";
+import { CLOSE_RECENT_LOGIN, CLOSE_UNAVAILABLE, ENDED_FOR_CLOSURE, closureRoutes } from "../closure";
import { entitledSql } from "../entitlement";
+import { ENDED_FOR_WITHDRAWAL, WITHDRAWAL_OPEN_DAYS } from "../withdrawal";
import { migrations } from "../migrations";
import { TERMS_VERSION } from "../policy-constants";
import { ORIGINS } from "./bundle";
@@ -42,18 +47,37 @@ async function fixture({ closed = false } = {}) {
const stripe = await stripeFake(context.time, context.random);
const sql = >(text: string, values?: unknown[]) =>
queryDatabase(context.database.url, text, values);
- /** user id → its session's user, as `get-session` answers it. */
+ /** user id → its session's user, as `get-session` answers it, and when that login began. */
const users = new Map();
- const app = new Hono();
- billingRoutes(app, () => ({
+ const logins = new Map();
+ const email = new EmailDev(context.time);
+ /** Mail sends to fail, before any goes out. */
+ const mailing = { failures: 0 };
+ const mail = () => ({
+ send: async (message: Parameters[0]) => {
+ if (mailing.failures > 0) {
+ mailing.failures--;
+ throw new Error("Postmark outage");
+ }
+ await email.send(message);
+ },
+ });
+ /** Each Burrow socket closure asked to close. */
+ const closedBurrows: string[] = [];
+ const host = {
databaseUrl: context.database.url,
- auth: async (request) => {
+ auth: async (request: Request) => {
const id = /(?:^|; )u=([^;]+)/.exec(request.headers.get("cookie") ?? "")?.[1];
const user = id && users.get(id);
- return Response.json(user ? { user, session: { createdAt: new Date().toISOString() } } : null);
+ return Response.json(user ? { user, session: { createdAt: logins.get(user.id) ?? new Date().toISOString() } } : null);
},
setup: (): BillingSetup | null => (closed ? null : stripe.setup),
- }));
+ mail,
+ closeBurrow: async (_userId: string, burrowId: string) => closedBurrows.push(burrowId),
+ };
+ const app = new Hono();
+ billingRoutes(app, () => host);
+ closureRoutes(app, () => host);
// The account Worker's `onError`.
app.onError((_error, c) => c.json({ message: "unavailable" }, 503));
@@ -146,6 +170,11 @@ async function fixture({ closed = false } = {}) {
buy,
metrics,
entitledOn,
+ email,
+ mail,
+ mailing,
+ logins,
+ closedBurrows,
close: async () => {
await stripe.close();
await context.close();
@@ -249,6 +278,9 @@ test("checkout takes a plan and a ref, never a Price, customer, quantity, or ret
founding: { cohort: 0, seatsLeft: FOUNDING_COHORT_SIZE - 1 },
founder: { offered: true, name: null },
consent: { termsVersion: TERMS_VERSION, consentedAt: expect.any(String) },
+ // No billing country: offered, as an EEA or UK consumer's would be.
+ withdrawal: { subscription: expect.any(String), startedAt: expect.any(String), until: expect.any(String), email: expect.any(String) },
+ withdrawn: null,
});
// The return page confirms the operation it names, and only a well-formed one.
for (const checkout of ["", "../x", "x".repeat(65), 7])
@@ -629,3 +661,192 @@ test("a count that fails rolls back alone and is logged: the webhook still commi
expect(error).toHaveBeenCalledWith(expect.stringContaining("Metric checkout.completed not recorded"));
expect((await f.metrics()).filter((row) => row.startsWith("checkout.completed"))).toEqual([]);
});
+
+test("an EEA or UK consumer withdraws online: recorded, acknowledged by email, refunded in full, and ended at once", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const member = await f.account();
+ const { session, subscription } = await f.buy(member, "yearly");
+ f.countries.set(session.customer as string, "DE");
+ // Two payments for the contract, one already refunded by hand.
+ f.paidInvoices.set("pi_w0", subscription.id);
+ f.refunds.push({ paymentIntent: "pi_w0", withdrawal: null });
+ f.paidInvoices.set("pi_w1", subscription.id);
+ const billingEmail = f.dev.customers.get(session.customer as string)!.email;
+
+ const offered = await member.summary();
+ expect(offered.withdrawal).toEqual({
+ subscription: subscription.id,
+ startedAt: expect.any(String),
+ until: expect.any(String),
+ email: billingEmail,
+ });
+ const { startedAt, until } = offered.withdrawal as { startedAt: string; until: string };
+ expect(Date.parse(until) - Date.parse(startedAt)).toBe(WITHDRAWAL_OPEN_DAYS * 24 * 60 * 60 * 1000);
+
+ const withdraw = (subscription: unknown) =>
+ member.request("/api/billing/withdraw", { method: "POST", body: { subscription } });
+ for (const malformed of ["", "cus_x", 7, null]) expect((await withdraw(malformed)).status, String(malformed)).toBe(400);
+ const other = await withdraw("sub_someone_else");
+ expect([other.status, await other.json()]).toEqual([409, { message: WITHDRAWAL_CLOSED }]);
+ expect(await f.sql(`SELECT id FROM dormouse_withdrawals`)).toEqual([]);
+
+ const answered = await withdraw(subscription.id);
+ expect(answered.status).toBe(200);
+ const [row] = await f.sql<{ id: string; userId: string; plan: string; country: string; submittedAt: Date }>(
+ `SELECT id, "userId", plan, country, "submittedAt" FROM dormouse_withdrawals WHERE "subscriptionId" = $1`,
+ [subscription.id],
+ );
+ expect(row).toMatchObject({ userId: member.id, plan: "yearly", country: "DE" });
+ expect(await answered.json()).toMatchObject({
+ plan: null,
+ withdrawal: null,
+ withdrawn: { reference: row!.id, subscription: subscription.id, plan: "yearly", acknowledged: true, settled: true },
+ });
+ expect(f.cancels).toEqual([{ subscription: subscription.id, comment: ENDED_FOR_WITHDRAWAL }]);
+ expect(f.refunds).toEqual([
+ { paymentIntent: "pi_w0", withdrawal: null },
+ { paymentIntent: "pi_w1", withdrawal: row!.id },
+ ]);
+ expect(await f.entitledOn(member.id)).toBe(false);
+ const [mail] = f.email.all();
+ expect(mail!.to).toEqual([billingEmail]);
+ for (const fact of [WITHDRAWAL_STATEMENT, row!.id, String(row!.submittedAt.getUTCFullYear())]) expect(mail!.text).toContain(fact);
+
+ // A second submission acknowledges the first again, and repeats nothing.
+ await f.deliver();
+ expect((await withdraw(subscription.id)).status).toBe(200);
+ expect([f.cancels.length, f.refunds.length, f.email.all().length]).toEqual([1, 2, 1]);
+ // A billing record: it outlives the account.
+ await f.sql(`DELETE FROM "user" WHERE id = $1`, [member.id]);
+ expect(await f.sql(`SELECT id FROM dormouse_withdrawals`)).toEqual([{ id: row!.id }]);
+});
+
+test("the online withdrawal is offered within its window, to an EEA, UK, or unknown billing country, and only once", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const abroad = await f.account();
+ const { session: abroadSession, subscription: abroadSubscription } = await f.buy(abroad, "monthly");
+ f.countries.set(abroadSession.customer as string, "US");
+ expect(await abroad.summary()).toMatchObject({ plan: "monthly", withdrawal: null });
+ const refused = await abroad.request("/api/billing/withdraw", { method: "POST", body: { subscription: abroadSubscription.id } });
+ expect([refused.status, await refused.json()]).toEqual([409, { message: WITHDRAWAL_CLOSED }]);
+
+ const unknown = await f.account();
+ const { subscription } = await f.buy(unknown, "monthly");
+ expect(await unknown.summary()).toMatchObject({ withdrawal: { subscription: subscription.id } });
+ // Past the window, it is closed, and the summary stops offering it.
+ await f.sql(
+ `UPDATE pgstencil_billing.subscriptions SET started_at = now() - ($2::int * interval '1 day') - interval '1 minute' WHERE id = $1`,
+ [subscription.id, WITHDRAWAL_OPEN_DAYS],
+ );
+ expect(await unknown.summary()).toMatchObject({ plan: "monthly", withdrawal: null });
+ expect((await unknown.request("/api/billing/withdraw", { method: "POST", body: { subscription: subscription.id } })).status).toBe(409);
+ expect(await f.sql(`SELECT id FROM dormouse_withdrawals`)).toEqual([]);
+});
+
+test("a withdrawal whose acknowledgement, cancellation, or refund fails still stands, and the cron finishes it", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ onTestFinished(() => error.mockRestore());
+ const member = await f.account();
+ const { subscription } = await f.buy(member, "founding");
+ f.paidInvoices.set("pi_w2", subscription.id);
+ f.mailing.failures = 1;
+ f.failing.cancels = 1;
+ const answered = await member.request("/api/billing/withdraw", { method: "POST", body: { subscription: subscription.id } });
+ expect(answered.status).toBe(200);
+ // Still held, but never offered again: the withdrawal stands.
+ expect(await answered.json()).toMatchObject({
+ plan: "founding",
+ withdrawal: null,
+ withdrawn: { subscription: subscription.id, acknowledged: false, settled: false },
+ });
+ expect([f.email.all().length, f.cancels.length, f.refunds.length]).toEqual([0, 0, 0]);
+
+ f.failing.refunds = 1;
+ await expect(resyncBilling(f.setup, f.database.url, origin, f.mail)).rejects.toThrow(AggregateError);
+ expect(await member.summary()).toMatchObject({ plan: null, withdrawn: { acknowledged: true, settled: false } });
+ expect([f.email.all().length, f.cancels.length, f.refunds.length]).toEqual([1, 1, 0]);
+ await resyncBilling(f.setup, f.database.url, origin, f.mail);
+ expect(await member.summary()).toMatchObject({ withdrawn: { acknowledged: true, settled: true } });
+ expect([f.email.all().length, f.cancels.length, f.refunds.length]).toEqual([1, 1, 1]);
+ // Finished, the next run does nothing.
+ await resyncBilling(f.setup, f.database.url, origin, f.mail);
+ expect([f.email.all().length, f.cancels.length, f.refunds.length]).toEqual([1, 1, 1]);
+});
+
+test("closing an account needs a recent login, cancels its subscription in Stripe first, and keeps the billing records", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const member = await f.account();
+ const { subscription } = await f.buy(member, "monthly");
+ await f.sql(`INSERT INTO dormouse_relay_burrows ("burrowId", "userId", "tokenHash") VALUES ($1, $2, $3)`, [
+ "b".repeat(22),
+ member.id,
+ "h".repeat(64),
+ ]);
+ const close = (body: unknown) => member.request("/api/account/close", { method: "POST", body });
+
+ f.logins.set(member.id, new Date(Date.now() - 60 * 60 * 1000).toISOString());
+ const stale = await close({ close: true });
+ expect([stale.status, await stale.json()]).toEqual([403, { message: CLOSE_RECENT_LOGIN }]);
+ f.logins.delete(member.id);
+ for (const body of [{}, { close: "yes" }]) expect((await close(body)).status, JSON.stringify(body)).toBe(400);
+ expect(
+ (await member.request("/api/account/close", { method: "POST", body: { close: true }, headers: { origin: ORIGINS.relay } })).status,
+ ).toBe(403);
+ expect(f.cancels).toEqual([]);
+
+ expect((await close({ close: true })).status).toBe(204);
+ expect(f.cancels).toEqual([{ subscription: subscription.id, comment: ENDED_FOR_CLOSURE }]);
+ expect(await f.sql(`SELECT status FROM pgstencil_billing.subscriptions WHERE id = $1`, [subscription.id])).toEqual([
+ { status: "canceled" },
+ ]);
+ expect(await f.sql(`SELECT id FROM "user" WHERE id = $1`, [member.id])).toEqual([]);
+ expect(await f.sql(`SELECT "userId", "canceledSubscriptions" FROM dormouse_account_closures`)).toEqual([
+ { userId: member.id, canceledSubscriptions: [subscription.id] },
+ ]);
+ expect(await f.sql(`SELECT "userId" FROM dormouse_checkout_consents`)).toEqual([{ userId: member.id }]);
+ expect(f.closedBurrows).toEqual(["b".repeat(22)]);
+
+ // An account billing never knew closes with nothing to cancel.
+ const plain = await f.account();
+ expect((await plain.request("/api/account/close", { method: "POST", body: { close: true } })).status).toBe(204);
+ expect(f.cancels).toHaveLength(1);
+ // A checkout left open is expired first, so it can never start a subscription for no one.
+ const pending = await f.account();
+ expect((await pending.request("/api/billing/checkout", { method: "POST", body: consent("monthly") })).status).toBe(200);
+ expect((await pending.request("/api/account/close", { method: "POST", body: { close: true } })).status).toBe(204);
+ expect([...f.dev.checkouts.values()].at(-1)!.status).toBe("expired");
+});
+
+test("with billing closed, closing deletes an account without a subscription and refuses one still holding one", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture({ closed: true });
+ onTestFinished(f.close);
+ const plain = await f.account();
+ expect((await plain.request("/api/account/close", { method: "POST", body: { close: true } })).status).toBe(204);
+ const held = await f.account();
+ await f.sql(`INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at) VALUES ($1, 'x@example.test', now())`, [
+ held.id,
+ ]);
+ await f.sql(
+ `INSERT INTO pgstencil_billing.subscriptions (id, owner_id, price_id, status, period_end, cancel_at_period_end, updated_at)
+ VALUES ('sub_held', $1, 'price_x', 'active', now() + interval '1 month', false, now())`,
+ [held.id],
+ );
+ const refused = await held.request("/api/account/close", { method: "POST", body: { close: true } });
+ expect([refused.status, await refused.json()]).toEqual([503, { message: CLOSE_UNAVAILABLE }]);
+ expect(await f.sql(`SELECT id FROM "user" WHERE id = $1`, [held.id])).toEqual([{ id: held.id }]);
+});
diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts
index 5a6cd4c69..70ebd081e 100644
--- a/hosted/server/tests/billing.test.ts
+++ b/hosted/server/tests/billing.test.ts
@@ -19,6 +19,10 @@ import { workerDatabases } from "./worker-roles";
const origin = ORIGINS.account;
const LADDER = DEV_FOUNDING_PRICES;
+/** No withdrawal in these tests asks for mail. */
+const noMail = () => {
+ throw new Error("No mail in this test");
+};
const zeros = () => Array(FOUNDING_LADDER.length).fill(0);
test("billing is closed without every Stripe binding, and refuses a ladder the page does not publish", () => {
@@ -244,7 +248,7 @@ test("the resync repairs a missed renewal, and does nothing while billing is clo
const f = await fixture();
onTestFinished(f.close);
// Closed: no setup, so not even the database is asked.
- await expect(resyncBilling(null, "postgres://user:pass@127.0.0.1:9/none", origin)).resolves.toBeUndefined();
+ await expect(resyncBilling(null, "postgres://user:pass@127.0.0.1:9/none", origin, noMail)).resolves.toBeUndefined();
const member = await f.user();
const subscription = await f.buy(member, "yearly");
@@ -253,7 +257,7 @@ test("the resync repairs a missed renewal, and does nothing while billing is clo
f.dev.events.length = 0;
await f.sql(`UPDATE pgstencil_billing.subscriptions SET period_end = now() - interval '1 minute'`);
expect(await f.entitledOn(member)).toEqual(everywhere(false));
- await resyncBilling(f.setup, f.database.url, origin);
+ await resyncBilling(f.setup, f.database.url, origin, noMail);
expect(await f.entitledOn(member)).toEqual(everywhere(true));
});
diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts
index fb9515db8..f0d4f366d 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -45,6 +45,8 @@ const PINNED: Record = {
"1610ccafe5450a7960a00cf7e393c6d99c22b20170ef230c76544632e6806eeb",
"016_checkout_consents.sql":
"0d0fcf0a44651ca819ee279d046ef55a622fa6398cf99e3b3d69b9a045ffe74c",
+ "017_withdrawals_and_closures.sql":
+ "136eb6ca31280e7f3852a0a42226235b0a639537b9ce64752efca66c091580c4",
};
const directory = new URL("../dormouse-migrations/", import.meta.url);
diff --git a/hosted/server/tests/stripe-fake.ts b/hosted/server/tests/stripe-fake.ts
index 9b3af70b3..0a419d1dd 100644
--- a/hosted/server/tests/stripe-fake.ts
+++ b/hosted/server/tests/stripe-fake.ts
@@ -6,10 +6,12 @@ import type { RandomSource, Time } from "pgstencil";
import { stripeDevBilling } from "../billing-dev";
/**
- * StripeDev behind a proxy that adds what billing's refund and dispute
- * handling call and StripeDev does not model: a dispute's retrieval, with
- * its charge expanded, and a subscription's immediate cancellation. Every
- * other request reaches StripeDev unchanged.
+ * StripeDev behind a proxy that adds what billing's refund, dispute, and
+ * withdrawal handling call and StripeDev does not model: a dispute's
+ * retrieval, with its charge expanded, a subscription's immediate
+ * cancellation, a customer's retrieval with its billing country, a
+ * subscription's paid invoices and their payments, and refunds. Every other
+ * request reaches StripeDev unchanged.
*/
export async function stripeFake(time: Time, random: RandomSource) {
const { dev, setup: devSetup } = await stripeDevBilling(time, random);
@@ -19,6 +21,12 @@ export async function stripeFake(time: Time, random: RandomSource) {
const paidInvoices = new Map();
/** Each cancellation billing asked for: the subscription and its comment. */
const cancels: { subscription: string; comment: string | null }[] = [];
+ /** Customer id → its billing address's country; none, no address. */
+ const countries = new Map();
+ /** Each refund made: its PaymentIntent and its metadata's withdrawal. */
+ const refunds: { paymentIntent: string; withdrawal: string | null }[] = [];
+ /** Refund and cancellation requests to fail, before any is made, with a Stripe outage. */
+ const failing = { refunds: 0, cancels: 0 };
const server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
@@ -37,9 +45,43 @@ export async function stripeFake(time: Time, random: RandomSource) {
charge: { id: `ch_for_${dispute[1]}`, object: "charge", customer: found.customer, payment_intent: found.paymentIntent },
});
}
+ const customer = /^\/v1\/customers\/([^/]+)$/.exec(url.pathname);
+ if (customer && req.method === "GET") {
+ const found = dev.customers.get(customer[1]!);
+ if (!found) return json(404, { error: { message: "No such customer" } });
+ const country = countries.get(customer[1]!);
+ return json(200, { ...found, address: country ? { country } : null });
+ }
+ if (url.pathname === "/v1/invoices" && req.method === "GET") {
+ const of = url.searchParams.get("subscription");
+ const paid = url.searchParams.get("status") === "paid";
+ return json(200, {
+ object: "list",
+ has_more: false,
+ data: [...paidInvoices]
+ .filter(([, subscriptionId]) => paid && subscriptionId === of)
+ .map(([intent]) => ({ id: `in_${intent}`, object: "invoice", status: "paid" })),
+ });
+ }
+ if (url.pathname === "/v1/refunds" && req.method === "POST") {
+ const body = new URLSearchParams(raw);
+ const intent = body.get("payment_intent")!;
+ if (failing.refunds > 0) {
+ failing.refunds--;
+ return json(500, { error: { type: "api_error", message: "Stripe fake outage" } });
+ }
+ if (refunds.some((refund) => refund.paymentIntent === intent))
+ return json(400, {
+ error: { type: "invalid_request_error", code: "charge_already_refunded", message: "Charge has already been refunded." },
+ });
+ refunds.push({ paymentIntent: intent, withdrawal: body.get("metadata[dormouse_withdrawal]") });
+ return json(200, { id: `re_${intent}`, object: "refund", payment_intent: intent, status: "succeeded" });
+ }
if (url.pathname === "/v1/invoice_payments" && req.method === "GET") {
- const intent = url.searchParams.get("payment[payment_intent]");
- const subscriptionId = intent && url.searchParams.get("payment[type]") === "payment_intent" && paidInvoices.get(intent);
+ const invoice = url.searchParams.get("invoice");
+ const intent = invoice?.startsWith("in_") ? invoice.slice(3) : url.searchParams.get("payment[payment_intent]");
+ const subscriptionId =
+ intent && (invoice || url.searchParams.get("payment[type]") === "payment_intent") && paidInvoices.get(intent);
return json(200, {
object: "list",
has_more: false,
@@ -48,6 +90,8 @@ export async function stripeFake(time: Time, random: RandomSource) {
{
id: `inpay_${intent}`,
object: "invoice_payment",
+ status: "paid",
+ payment: { type: "payment_intent", payment_intent: intent },
invoice: {
id: `in_${intent}`,
object: "invoice",
@@ -60,6 +104,10 @@ export async function stripeFake(time: Time, random: RandomSource) {
}
const subscription = /^\/v1\/subscriptions\/([^/]+)$/.exec(url.pathname);
if (subscription && req.method === "DELETE") {
+ if (failing.cancels > 0) {
+ failing.cancels--;
+ return json(500, { error: { type: "api_error", message: "Stripe fake outage" } });
+ }
// The SDK sends a DELETE's parameters in its query.
const comment = new URLSearchParams(raw || url.search).get("cancellation_details[comment]");
cancels.push({ subscription: subscription[1]!, comment });
@@ -111,6 +159,9 @@ export async function stripeFake(time: Time, random: RandomSource) {
disputes,
paidInvoices,
cancels,
+ countries,
+ refunds,
+ failing,
event,
close: async () => {
server.close();
diff --git a/hosted/server/withdrawal.ts b/hosted/server/withdrawal.ts
new file mode 100644
index 000000000..c88bf2b85
--- /dev/null
+++ b/hosted/server/withdrawal.ts
@@ -0,0 +1,287 @@
+// Rules: docs/specs/hosted.md -> "Billing"; docs/specs/pricing.md ->
+// "Checkout and entitlement".
+import type { Billing } from "@pgstencil/stripe";
+import { escape } from "@pgstencil/auth/email";
+import { CompiledQuery } from "kysely";
+import type { EmailMessage, EmailSender } from "pgstencil";
+import { HOSTED_MONTHLY, HOSTED_YEARLY } from "../../website/src/lib/hosted-pricing";
+import { DIFFPLUG_POSTAL_ADDRESS, SUPPORT_EMAIL, WITHDRAWAL_STATEMENT } from "../../website/src/lib/hosted-withdrawal";
+import type { Plan } from "./billing";
+import { CURRENT_STATUSES, ENDED_STATUSES } from "./entitlement";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * How long after a subscription starts the online withdrawal stays open. The
+ * statutory period ends at the end of the 14th day after the contract, in
+ * the consumer's time zone, and later when that day is a weekend or public
+ * holiday; three weeks outlasts every such end.
+ */
+export const WITHDRAWAL_OPEN_DAYS = 21;
+
+/**
+ * Billing countries whose consumers hold the EEA or UK withdrawal right: the
+ * EU's members and its outermost regions Stripe lists apart, Iceland,
+ * Liechtenstein, Norway, and the United Kingdom, plus the Crown Dependencies
+ * and Gibraltar, offered rather than ruled out. A customer with no country
+ * is offered it too.
+ */
+export const WITHDRAWAL_COUNTRIES: ReadonlySet = new Set([
+ ...["AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU"],
+ ...["LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK"],
+ ...["AX", "GF", "GP", "MF", "MQ", "RE", "YT"],
+ ...["IS", "LI", "NO"],
+ ...["GB", "GG", "GI", "IM", "JE"],
+]);
+
+/** The cancellation comment a subscription ended by withdrawal carries. */
+export const ENDED_FOR_WITHDRAWAL = "dormouse: EEA or UK withdrawal";
+
+/** How long the cron keeps retrying a withdrawal's acknowledgement or settlement. */
+const RETRY_DAYS = 30;
+
+const PLAN_NAMES: Record = {
+ monthly: HOSTED_MONTHLY.name,
+ yearly: HOSTED_YEARLY.name,
+ founding: "Founding",
+};
+
+type Held = NonNullable["status"]>>["subscription"]>;
+
+/** The online withdrawal the billing page offers for the held subscription. */
+export interface WithdrawalOffer {
+ subscription: string;
+ startedAt: string;
+ /** When the online withdrawal closes. */
+ until: string;
+ /** Where the acknowledgement goes: the Stripe customer's email, null when Stripe could not say. */
+ email: string | null;
+ country: string | null;
+}
+
+/** A recorded withdrawal, as `dormouse_withdrawals` holds it. */
+export interface WithdrawalRow {
+ id: string;
+ userId: string;
+ subscriptionId: string;
+ plan: string;
+ contractStartedAt: Date;
+ submittedAt: Date;
+ acknowledgedAt: Date | null;
+ settledAt: Date | null;
+}
+
+/** The Stripe customer's billing email and address country, or null when Stripe cannot say. */
+async function billingContact(billing: Billing, ownerId: string) {
+ const account = await billing.db
+ .selectFrom("accounts")
+ .select("customer_id")
+ .where("owner_id", "=", ownerId)
+ .executeTakeFirst();
+ if (!account?.customer_id) return null;
+ try {
+ const customer = await billing.stripe.customers.retrieve(account.customer_id);
+ if (customer.deleted) return null;
+ return { email: customer.email ?? null, country: customer.address?.country?.toUpperCase() ?? null };
+ } catch (error) {
+ console.warn(`Reading Stripe customer ${account.customer_id} failed`, error);
+ return null;
+ }
+}
+
+/**
+ * The online withdrawal from `subscription`, or null: offered while it is
+ * current, within {@link WITHDRAWAL_OPEN_DAYS} of its start, and its billing
+ * country is in {@link WITHDRAWAL_COUNTRIES} or unknown, a Stripe that cannot
+ * say included. Whether it was withdrawn already is the caller's.
+ */
+export async function withdrawalOffer(
+ billing: Billing,
+ ownerId: string,
+ subscription: Held | null,
+): Promise {
+ if (!subscription?.started_at || !CURRENT_STATUSES.includes(subscription.status)) return null;
+ const startedAt = new Date(subscription.started_at);
+ const until = new Date(startedAt.getTime() + WITHDRAWAL_OPEN_DAYS * DAY_MS);
+ if (billing.time.now() >= until) return null;
+ const contact = await billingContact(billing, ownerId);
+ if (contact?.country && !WITHDRAWAL_COUNTRIES.has(contact.country)) return null;
+ return {
+ subscription: subscription.id,
+ startedAt: startedAt.toISOString(),
+ until: until.toISOString(),
+ email: contact?.email ?? null,
+ country: contact?.country ?? null,
+ };
+}
+
+const run = (billing: Billing, text: string, values: unknown[]) =>
+ billing.db.executeQuery(CompiledQuery.raw(text, values)).then((result) => result.rows);
+
+const COLUMNS = `id, "userId", "subscriptionId", plan, "contractStartedAt", "submittedAt", "acknowledgedAt", "settledAt"`;
+
+/** The withdrawal recorded from `subscriptionId` by `ownerId`, if any. */
+export async function recordedWithdrawal(billing: Billing, ownerId: string, subscriptionId: string) {
+ const [row] = await run(
+ billing,
+ `SELECT ${COLUMNS} FROM public.dormouse_withdrawals WHERE "subscriptionId" = $1 AND "userId" = $2`,
+ [subscriptionId, ownerId],
+ );
+ return row ?? null;
+}
+
+/**
+ * Records `ownerId`'s withdrawal from `subscription` under `offer`, submitted
+ * now, and answers the row: a second submission answers the first's.
+ */
+export async function recordWithdrawal(
+ billing: Billing,
+ ownerId: string,
+ plan: Plan,
+ subscription: Held,
+ offer: WithdrawalOffer,
+): Promise {
+ await run(
+ billing,
+ `INSERT INTO public.dormouse_withdrawals
+ (id, "userId", "subscriptionId", plan, "priceId", "contractStartedAt", country, "submittedAt")
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT ("subscriptionId") DO NOTHING`,
+ [
+ crypto.randomUUID(),
+ ownerId,
+ subscription.id,
+ plan,
+ subscription.price_id,
+ offer.startedAt,
+ offer.country,
+ billing.time.now(),
+ ],
+ );
+ return (await recordedWithdrawal(billing, ownerId, subscription.id))!;
+}
+
+const UTC = new Intl.DateTimeFormat("en-GB", { dateStyle: "long", timeStyle: "long", timeZone: "UTC" });
+const UTC_DAY = new Intl.DateTimeFormat("en-GB", { dateStyle: "long", timeZone: "UTC" });
+
+/** The durable acknowledgement of withdrawal `row`, emailed to `to`. */
+export function withdrawalAcknowledgement(to: string, row: WithdrawalRow): EmailMessage {
+ const plan = PLAN_NAMES[row.plan as Plan] ?? row.plan;
+ const facts: [string, string][] = [
+ ["Your statement", WITHDRAWAL_STATEMENT],
+ ["Submitted", UTC.format(row.submittedAt)],
+ ["Contract", `Dormouse Hosted, ${plan}, ordered on ${UTC_DAY.format(row.contractStartedAt)}`],
+ ["Reference", row.id],
+ ];
+ const refund =
+ "Your subscription ends with this withdrawal and will not renew. We refund every payment you made for it in full, including tax, to your original payment method within 14 days of your withdrawal, with no deduction. Your bank or card provider decides when the credit appears.";
+ const keep = `Keep this email as your record. Questions: ${SUPPORT_EMAIL}.`;
+ const intro = "DiffPlug LLC received your withdrawal from your Dormouse Hosted subscription contract.";
+ return {
+ from: `Dormouse Hosted <${SUPPORT_EMAIL}>`,
+ to: [to],
+ replyTo: SUPPORT_EMAIL,
+ subject: "We received your withdrawal from Dormouse Hosted",
+ text: [intro, facts.map(([term, value]) => `${term}: ${value}`).join("\n"), refund, keep, DIFFPLUG_POSTAL_ADDRESS].join(
+ "\n\n",
+ ),
+ html: `
${escape(intro)}
${facts
+ .map(([term, value]) => `
${escape(term)}
${escape(value)}
`)
+ .join("")}
${escape(refund)}
${escape(keep)}
${escape(DIFFPLUG_POSTAL_ADDRESS)}
`,
+ };
+}
+
+/** Refunds what is left of PaymentIntent `intent` for withdrawal `id`, once however often it is asked. */
+async function refundInFull(billing: Billing, id: string, intent: string) {
+ try {
+ await billing.stripe.refunds.create(
+ { payment_intent: intent, reason: "requested_by_customer", metadata: { dormouse_withdrawal: id } },
+ { idempotencyKey: `dormouse-withdrawal-${id}-${intent}` },
+ );
+ } catch (error) {
+ // Refunded in full already, by an earlier attempt or by hand.
+ if ((error as { code?: unknown }).code === "charge_already_refunded") return;
+ throw error;
+ }
+}
+
+/**
+ * Ends withdrawal `row`'s subscription at once, without proration or a final
+ * invoice, then refunds in full every payment its paid invoices received:
+ * no use deduction, tax included. Each step is safe to repeat.
+ */
+async function settle(billing: Billing, row: WithdrawalRow) {
+ await billing.reconcile(row.userId);
+ const synced = await billing.db
+ .selectFrom("subscriptions")
+ .select("status")
+ .where("id", "=", row.subscriptionId)
+ .executeTakeFirst();
+ if (synced && !ENDED_STATUSES.includes(synced.status)) {
+ await billing.stripe.subscriptions.cancel(row.subscriptionId, {
+ cancellation_details: { comment: ENDED_FOR_WITHDRAWAL },
+ prorate: false,
+ invoice_now: false,
+ });
+ await billing.reconcile(row.userId);
+ }
+ const invoices = await billing.stripe.invoices.list({ subscription: row.subscriptionId, status: "paid", limit: 100 });
+ if (invoices.has_more) throw new Error(`Withdrawal ${row.id}: more paid invoices than one page`);
+ for (const invoice of invoices.data) {
+ const payments = await billing.stripe.invoicePayments.list({ invoice: invoice.id!, status: "paid", limit: 100 });
+ for (const { payment } of payments.data) {
+ const intent = typeof payment.payment_intent === "string" ? payment.payment_intent : payment.payment_intent?.id;
+ if (intent) await refundInFull(billing, row.id, intent);
+ else console.warn(`Withdrawal ${row.id}: invoice ${invoice.id} has a payment Stripe cannot refund here; refund it by hand`);
+ }
+ }
+}
+
+/**
+ * Finishes withdrawal `row`: emails its acknowledgement to the billing email,
+ * then settles it, each step skipped once done and stamped once it is. Throws
+ * after both were tried if either failed; the cron tries again.
+ */
+export async function completeWithdrawal(billing: Billing, mail: () => EmailSender, row: WithdrawalRow) {
+ const failed: unknown[] = [];
+ const stamp = (column: "acknowledgedAt" | "settledAt") =>
+ run(billing, `UPDATE public.dormouse_withdrawals SET "${column}" = $2 WHERE id = $1`, [row.id, billing.time.now()]);
+ if (!row.acknowledgedAt)
+ try {
+ const email = (await billingContact(billing, row.userId))?.email;
+ if (!email) throw new Error(`Withdrawal ${row.id}: Stripe gave no billing email to acknowledge to`);
+ await mail().send(withdrawalAcknowledgement(email, row));
+ await stamp("acknowledgedAt");
+ } catch (error) {
+ failed.push(error);
+ }
+ if (!row.settledAt)
+ try {
+ await settle(billing, row);
+ await stamp("settledAt");
+ } catch (error) {
+ failed.push(error);
+ }
+ if (failed.length) throw new AggregateError(failed, `Withdrawal ${row.id} is not complete`);
+}
+
+/** How many withdrawals one cron run finishes at most. */
+const RETRY_BATCH = 20;
+
+/**
+ * The cron's pass: finishes every withdrawal submitted in the last
+ * {@link RETRY_DAYS} days whose acknowledgement or settlement failed, up to
+ * {@link RETRY_BATCH}; answers the failures.
+ */
+export async function retryWithdrawals(billing: Billing, mail: () => EmailSender): Promise {
+ const rows = await run(
+ billing,
+ `SELECT ${COLUMNS} FROM public.dormouse_withdrawals
+ WHERE ("acknowledgedAt" IS NULL OR "settledAt" IS NULL) AND "submittedAt" > $1
+ ORDER BY "submittedAt" LIMIT $2`,
+ [new Date(billing.time.now().getTime() - RETRY_DAYS * DAY_MS), RETRY_BATCH],
+ );
+ const failed: unknown[] = [];
+ for (const row of rows) await completeWithdrawal(billing, mail, row).catch((error: unknown) => failed.push(error));
+ return failed;
+}
diff --git a/website/src/lib/hosted-withdrawal.ts b/website/src/lib/hosted-withdrawal.ts
new file mode 100644
index 000000000..e67acc41d
--- /dev/null
+++ b/website/src/lib/hosted-withdrawal.ts
@@ -0,0 +1,13 @@
+// The EEA and UK withdrawal wording the Terms, the account app's checkout and
+// billing pages, and the emailed acknowledgement share, so a statement never
+// differs between them.
+
+export const SUPPORT_EMAIL = "support@dormouse.sh";
+
+export const DIFFPLUG_POSTAL_ADDRESS = "DiffPlug LLC, 447 Sutter St Ste 405, San Francisco, CA 94108, United States";
+
+/** The statement the online withdrawal function submits for the consumer. */
+export const WITHDRAWAL_STATEMENT = "I give notice that I withdraw from my contract for Dormouse Hosted.";
+
+/** The optional model withdrawal form, for email or post. */
+export const MODEL_WITHDRAWAL_FORM = `To ${DIFFPLUG_POSTAL_ADDRESS}; ${SUPPORT_EMAIL}: ${WITHDRAWAL_STATEMENT} Ordered on: ____. Name: ____. Address: ____. Date: ____. Signature, only if sent on paper: ____.`;
From ddaddb90f5cdd5e7ab25de69ca3368d593fd4a98 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 06:00:44 -0700
Subject: [PATCH 2/5] Hosted: withdrawal, its acknowledgement, and account
closure in the account app
Checkout discloses the EEA/UK withdrawal right, where the online withdrawal
is, the support path, and the model form. /billing adds the Withdrawal row
to the confirmation and the Withdraw from contract function, which shows the
contract, the statement, and where the acknowledgement goes before Confirm
withdrawal, then keeps the acknowledgement. The account page links to it and
gains Close account, whose dialog says what closing does to the plan first.
The founders opt-in names the profile picture it shows.
Co-Authored-By: Claude Opus 5.5
---
hosted/src/App.test.tsx | 75 +++++++++++-
hosted/src/App.tsx | 20 ++++
hosted/src/Checkout.tsx | 2 +
hosted/src/Closure.tsx | 114 +++++++++++++++++++
hosted/src/HostedPage.tsx | 19 +++-
hosted/src/Plan.tsx | 37 ++++--
hosted/src/Withdrawal.tsx | 152 +++++++++++++++++++++++++
hosted/src/api.ts | 16 +++
hosted/src/billing.ts | 28 +++++
hosted/src/stories/Account.stories.tsx | 30 ++++-
hosted/src/stories/Billing.stories.tsx | 30 ++++-
hosted/src/stories/fixtures.tsx | 18 ++-
12 files changed, 525 insertions(+), 16 deletions(-)
create mode 100644 hosted/src/Closure.tsx
create mode 100644 hosted/src/Withdrawal.tsx
diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx
index d8ae0be42..dfc005e25 100644
--- a/hosted/src/App.test.tsx
+++ b/hosted/src/App.test.tsx
@@ -9,6 +9,7 @@ import type { Enrollment } from "./enrollment";
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";
+import { WITHDRAWAL_STATEMENT } from "../../website/src/lib/hosted-withdrawal";
vi.mock("./api", async (original) => ({
...await original(),
@@ -17,7 +18,7 @@ vi.mock("./api", async (original) => ({
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(),
+ openPortal: vi.fn(), setFounder: vi.fn(), withdraw: vi.fn(), closeAccount: vi.fn(),
}));
const NOW = Date.UTC(2026, 9, 21, 12);
@@ -325,7 +326,7 @@ describe("approveEnrollment", () => {
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,
+ founding: { cohort: 0, seatsLeft: 42 }, founder: { offered: false, name: null }, consent: null, withdrawal: null, withdrawn: null,
};
const FOUNDING: BillingSummary = {
...NO_PLAN, plan: "founding", price: 50, active: true, until: "2027-10-21T12:00:00Z", renews: true, entitled: true,
@@ -487,6 +488,76 @@ describe("the plan section", () => {
});
});
+describe("withdrawal and closure", () => {
+ const OFFER = {
+ subscription: "sub_contract1",
+ startedAt: "2026-10-21T12:00:00Z",
+ until: "2026-11-11T12:00:00Z",
+ email: "billing@example.com",
+ };
+ const WITHDRAWABLE: BillingSummary = { ...FOUNDING, withdrawal: OFFER };
+ const heading = () => container.querySelector("h1")?.textContent;
+ beforeEach(() => {
+ vi.mocked(api.getSession).mockResolvedValue(login());
+ });
+
+ it("shows the contract and statement before confirming, and keeps the acknowledgement after", async () => {
+ history.replaceState(null, "", "/billing");
+ vi.mocked(api.getBilling).mockResolvedValue(WITHDRAWABLE);
+ const withdrawn = {
+ reference: "wd-reference-1", subscription: OFFER.subscription, plan: "founding", startedAt: OFFER.startedAt,
+ submittedAt: "2026-10-22T09:30:00Z", acknowledged: true, settled: true,
+ };
+ vi.mocked(api.withdraw).mockResolvedValue({ ...NO_PLAN, withdrawn });
+ await mount(null);
+ expect(text()).toContain("Model withdrawal form");
+ expect(text()).not.toContain(OFFER.subscription);
+ await click("Withdraw from contract");
+ expect(api.withdraw).not.toHaveBeenCalled();
+ for (const shown of [OFFER.subscription, OFFER.email, WITHDRAWAL_STATEMENT]) expect(text()).toContain(shown);
+ await click("Keep my plan");
+ expect(text()).not.toContain(OFFER.subscription);
+ await click("Withdraw from contract");
+ await click("Confirm withdrawal");
+ expect(api.withdraw).toHaveBeenCalledWith(OFFER.subscription);
+ expect(text()).toContain("Withdrawal received");
+ expect(text()).toContain("wd-reference-1");
+ expect(text()).toContain("We emailed this acknowledgement");
+ expect(container.querySelector("#withdraw")).toBeNull();
+ });
+
+ it("explains what closing does to the plan before closing, then signs the page out", async () => {
+ vi.mocked(api.getBilling).mockResolvedValue(WITHDRAWABLE);
+ vi.mocked(api.closeAccount).mockResolvedValue(undefined);
+ await mount(null);
+ await click("Close account…");
+ expect(container.querySelector("dialog[aria-labelledby=close-title]")!.open).toBe(true);
+ for (const said of ["Your Founding plan ends now instead of on", "Closing does not withdraw", "cancel renewal in Manage billing"])
+ expect(text()).toContain(said);
+ expect(api.closeAccount).not.toHaveBeenCalled();
+ vi.mocked(api.getSession).mockResolvedValue(null);
+ await click("Close account");
+ expect(api.closeAccount).toHaveBeenCalled();
+ expect(text()).toContain("Your account is closed.");
+ expect(heading()).toBe("Sign in to Dormouse Hosted");
+ });
+
+ it("needs a recent login, and a plan it could read, to close", async () => {
+ vi.mocked(api.getSession).mockResolvedValue(login(60 * 60 * 1000));
+ vi.mocked(api.getBilling).mockResolvedValue(WITHDRAWABLE);
+ await mount(null);
+ expect(button("Close account…").disabled).toBe(true);
+ await act(async () => root.unmount());
+ root = createRoot(container);
+ vi.mocked(api.getSession).mockResolvedValue(login());
+ vi.mocked(api.getBilling).mockRejectedValue(new Error("Billing is temporarily unavailable. Try again."));
+ await mount(null);
+ await click("Close account…");
+ expect(button("Close account").disabled).toBe(true);
+ expect(text()).toContain("Your plan could not load");
+ });
+});
+
describe("startCheckout", () => {
afterEach(() => vi.restoreAllMocks());
diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx
index c79393f38..b96d014c6 100644
--- a/hosted/src/App.tsx
+++ b/hosted/src/App.tsx
@@ -8,6 +8,7 @@ import {
import {
acceptTerms,
approveEnrollment,
+ closeAccount as closeAccountRequest,
confirmCheckout,
getAccounts,
getBilling,
@@ -27,6 +28,7 @@ import {
social,
startCheckout,
turnOffHostedVoice,
+ withdraw as withdrawRequest,
type Account,
type Computer,
type HostedVoice,
@@ -461,6 +463,23 @@ export function App({
setBilling(await setFounder(shown, name));
setNotice({ text: shown ? `You are in the founders row as ${name}.` : "You left the founders row." });
});
+ const withdraw = (subscription: string) =>
+ act("withdraw", async () => {
+ setBilling(await withdrawRequest(subscription));
+ window.scrollTo?.(0, 0);
+ });
+ const closeAccount = () =>
+ mutate("close", async () => {
+ await closeAccountRequest();
+ setSession(null);
+ setAccounts([]);
+ setHostedVoice(null);
+ setComputers(null);
+ setPasskeys(null);
+ setBilling(null);
+ setRefusedPlan(false);
+ setNotice({ text: "Your account is closed. We keep billing records as the privacy policy describes." });
+ });
const leaveBilling = () => {
forgetCheckout();
setView("account");
@@ -490,6 +509,7 @@ export function App({
return
+
{shown && (
<>
@@ -130,6 +130,15 @@ export function PlanSection({
{billing.price ? ` · ${perInterval(billing.price, interval(billing))}` : ""}
{planStatus(billing)}
+ {billing.withdrawal && (
+
+ In the EEA or UK, you can withdraw from this contract until {day(billing.withdrawal.until)} for a full refund:{" "}
+
+ Withdraw from contract
+
+ .
+
+ )}
{children}
>
@@ -180,7 +189,10 @@ export function PlanConfirmation({
: []),
[
"Cancel",
- "Any time in Manage billing. You keep Hosted until the end of the period you paid for.",
+ <>
+ Any time in Manage billing. You keep Hosted until the end of the period you paid for. If you cannot sign in,
+ email to cancel.
+ >,
],
[
"Refunds",
@@ -188,13 +200,22 @@ export function PlanConfirmation({
{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.
+ , email for a full refund, which ends the plan.
>,
],
+ ...(billing.withdrawal
+ ? ([
+ [
+ "Withdrawal",
+ <>
+ In the EEA or UK, you can withdraw from this contract until {day(billing.withdrawal.until)} without giving a
+ reason, and we refund every payment for it, including tax, with no deduction for the time you used. Use
+ Withdraw from contract below, or email , even if you can no longer sign in.
+
+ >,
+ ],
+ ] as [string, ReactNode][])
+ : []),
["Receipt", "Stripe emails a receipt for each payment to your billing email."],
];
return (
diff --git a/hosted/src/Withdrawal.tsx b/hosted/src/Withdrawal.tsx
new file mode 100644
index 000000000..82d7e5ebc
--- /dev/null
+++ b/hosted/src/Withdrawal.tsx
@@ -0,0 +1,152 @@
+import { useState, type ReactNode } from "react";
+import { CARD_ACCENT_CLASS, CARD_CLASS, LINK_CLASS, MUTED_TEXT_CLASS } from "dormouse-lib/site/docs-tokens";
+import { MODEL_WITHDRAWAL_FORM, SUPPORT_EMAIL, WITHDRAWAL_STATEMENT } from "../../website/src/lib/hosted-withdrawal";
+import { isPlan, planName, type WithdrawalOffer, type Withdrawn } from "./billing";
+import { BUTTON_CLASS, HEADING_CLASS, HELP_CLASS, LABEL_TEXT_CLASS, PRIMARY_CLASS } from "./classes";
+
+const TERMS_WITHDRAWAL_URL = "https://dormouse.sh/terms/#withdrawal";
+const day = (iso: string) => new Date(iso).toLocaleDateString();
+const contract = (plan: string, startedAt: string) =>
+ `Dormouse Hosted, ${isPlan(plan) ? planName(plan) : plan}, ordered on ${day(startedAt)}`;
+
+export const SupportLink = () => (
+
+ {SUPPORT_EMAIL}
+
+);
+
+/** The optional model withdrawal form, folded, with the Terms' section beside it. */
+export function ModelForm() {
+ return (
+
+ Model withdrawal form
+
You may copy this into an email or a letter. Using it is optional.
+
+ {MODEL_WITHDRAWAL_FORM}
+
+
+ The{" "}
+
+ Terms
+ {" "}
+ describe the right in full.
+
+
+ );
+}
+
+/**
+ * The EEA and UK withdrawal right as checkout discloses it before purchase
+ * (docs/specs/pricing.md -> "Checkout and entitlement"): what it gives, where
+ * the online withdrawal is, the support path, and the model form.
+ */
+export function WithdrawalRight() {
+ return (
+
+
+ In the EEA or UK, you can withdraw within 14 days of buying without giving a reason, and we refund every payment,
+ including tax, with no deduction for the time you used. Use Withdraw from contract on your billing page, or email{" "}
+ , even if you can no longer sign in.
+
+
+
+ );
+}
+
+/**
+ * The online withdrawal function the billing page offers while the
+ * withdrawal is open: the consumer sees which contract it ends, where the
+ * acknowledgement goes, and the statement, then confirms. Distinct from
+ * cancelling renewal, which the portal does.
+ */
+export function WithdrawalFunction({
+ offer,
+ plan,
+ busy,
+ onWithdraw,
+}: {
+ offer: WithdrawalOffer;
+ plan: string;
+ busy: string;
+ onWithdraw: (subscription: string) => void;
+}) {
+ const [confirming, setConfirming] = useState(false);
+ const rows: [string, ReactNode][] = [
+ ["Contract", contract(plan, offer.startedAt)],
+ ["Contract reference", offer.subscription],
+ ["Your statement", WITHDRAWAL_STATEMENT],
+ ["Acknowledgement sent to", offer.email ?? "the email on your Stripe receipts"],
+ ];
+ return (
+
+
+ Withdraw from contract
+
+
+ You can withdraw from this contract until {day(offer.until)}, without giving a reason. Your plan ends straight away,
+ and we refund every payment for it in full, including tax, within 14 days. This is not the same as cancelling
+ renewal in Manage billing, which keeps Hosted until the end of the period you paid for.
+
+ {withdrawn.settled
+ ? "Your plan has ended. We refunded every payment for it in full, including tax; your bank or card provider decides when the credit appears."
+ : "Your plan ends with this withdrawal. We refund every payment for it in full, including tax, within 14 days."}
+
+
+ {withdrawn.acknowledged
+ ? "We emailed this acknowledgement to your billing email. Keep it as your record."
+ : "We are emailing this acknowledgement to your billing email. You can also print or save this page."}{" "}
+ Questions: .
+
+
+ );
+}
diff --git a/hosted/src/api.ts b/hosted/src/api.ts
index 73ba1655f..f67001c06 100644
--- a/hosted/src/api.ts
+++ b/hosted/src/api.ts
@@ -280,6 +280,22 @@ export async function openPortal() {
if (!response.ok) throw await billingRefused(response);
leaveFor(((await response.json()) as { url: string }).url, BILLING_UNAVAILABLE);
}
+/** Withdraws from the contract `subscription` names, answering the plan with its acknowledgement. */
+export async function withdraw(subscription: string): Promise {
+ const response = await billing("/withdraw", sendJson("POST", { subscription }));
+ if (!response.ok) throw await billingRefused(response);
+ return (await response.json()) as BillingSummary;
+}
+/** Closes this account, cancelling its subscription first; its login ends with it. */
+export async function closeAccount() {
+ const response = await request(
+ "/api/account/close",
+ sendJson("POST", { close: true }),
+ "Your account cannot be closed right now. Try again.",
+ true,
+ );
+ if (!response.ok) throw await refused(response);
+}
/** 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 }));
diff --git a/hosted/src/billing.ts b/hosted/src/billing.ts
index 1c7061eaf..bb149380a 100644
--- a/hosted/src/billing.ts
+++ b/hosted/src/billing.ts
@@ -30,6 +30,34 @@ export interface BillingSummary {
founder: { offered: boolean; name: string | null };
/** What the account agreed to when it bought the held subscription. */
consent: { termsVersion: string; consentedAt: string } | null;
+ /** The EEA or UK online withdrawal from the held subscription, while it is open. */
+ withdrawal: WithdrawalOffer | null;
+ /** The account's latest withdrawal, kept on the page as its acknowledgement. */
+ withdrawn: Withdrawn | null;
+}
+
+/** The held subscription's online withdrawal (docs/specs/hosted.md -> "Billing"). */
+export interface WithdrawalOffer {
+ /** The contract: the Stripe subscription it ends. */
+ subscription: string;
+ startedAt: string;
+ /** When the online withdrawal closes. */
+ until: string;
+ /** Where the acknowledgement is emailed; null when Stripe could not say. */
+ email: string | null;
+}
+
+/** A recorded withdrawal. */
+export interface Withdrawn {
+ reference: string;
+ subscription: string;
+ plan: string;
+ startedAt: string;
+ submittedAt: string;
+ /** Its acknowledgement was emailed. */
+ acknowledged: boolean;
+ /** The subscription ended and every payment for it was refunded. */
+ settled: boolean;
}
/** What `/checkout` arrived with, kept through a provider's sign-in. */
diff --git a/hosted/src/stories/Account.stories.tsx b/hosted/src/stories/Account.stories.tsx
index 41b357702..a1a5cecee 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, NO_PLAN, FOUNDING } from "./fixtures";
+import { hostedMeta, SESSION, ACCOUNT, COMPUTERS, PASSKEYS, NO_PLAN, FOUNDING, WITHDRAWABLE } from "./fixtures";
import { settledFrame } from "./canvas";
const meta = {
@@ -67,3 +67,31 @@ export const PlanSuspended: Story = { args: { ...ACCOUNT, billing: { ...FOUNDING
export const PlanComp: 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 } };
+export const PlanWithdrawable: Story = { args: { ...ACCOUNT, billing: WITHDRAWABLE } };
+/** What closing does to the plan, said before it is confirmed. */
+export const CloseAccountDialog: Story = {
+ args: { ...ACCOUNT, billing: WITHDRAWABLE, closeAccount: fn(async () => {}) },
+ play: async ({ canvasElement, args }) => {
+ const canvas = await settledFrame(canvasElement);
+ await canvas.user.click(canvas.getByRole("button", { name: "Close account…" }));
+ await expect(canvas.getByRole("dialog")).toBeVisible();
+ await canvas.user.click(canvas.getByRole("button", { name: "Close account" }));
+ await expect(args.closeAccount).toHaveBeenCalled();
+ await canvas.user.click(canvas.getByRole("button", { name: "Close account…" }));
+ },
+};
+export const CloseAccountNoPlan: Story = {
+ args: { ...ACCOUNT, billing: "closed" },
+ play: async ({ canvasElement }) => {
+ const canvas = await settledFrame(canvasElement);
+ await canvas.user.click(canvas.getByRole("button", { name: "Close account…" }));
+ await expect(canvas.getByText("You have no plan, so nothing is charged or refunded.")).toBeVisible();
+ },
+};
+export const CloseAccountStaleLogin: Story = {
+ args: { ...ACCOUNT, billing: FOUNDING, fresh: false },
+ play: async ({ canvasElement }) => {
+ const canvas = await settledFrame(canvasElement);
+ await expect(canvas.getByRole("button", { name: "Close account…" })).toBeDisabled();
+ },
+};
diff --git a/hosted/src/stories/Billing.stories.tsx b/hosted/src/stories/Billing.stories.tsx
index f5b448229..20dc27193 100644
--- a/hosted/src/stories/Billing.stories.tsx
+++ b/hosted/src/stories/Billing.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn } from "storybook/test";
-import { hostedMeta, SESSION, NO_PLAN, FOUNDING } from "./fixtures";
+import { hostedMeta, SESSION, NO_PLAN, FOUNDING, WITHDRAWABLE, WITHDRAWN } from "./fixtures";
import { settledFrame } from "./canvas";
const meta = {
@@ -27,7 +27,7 @@ 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");
+ const shown = canvas.getByLabelText("Show my name and profile picture 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");
@@ -40,3 +40,29 @@ export const JoinFounders: Story = {
canvas.document.defaultView!.scrollTo(0, 0);
},
};
+/** In the EEA or UK, within the withdrawal period: the disclosure row and the withdrawal function. */
+export const Withdrawable: Story = { args: { billing: WITHDRAWABLE } };
+/** The contract, the statement, and where the acknowledgement goes, before confirming. */
+export const WithdrawConfirm: Story = {
+ args: { billing: WITHDRAWABLE, withdraw: fn() },
+ play: async ({ canvasElement, args }) => {
+ const canvas = await settledFrame(canvasElement);
+ await canvas.user.click(canvas.getByRole("button", { name: "Withdraw from contract" }));
+ await expect(args.withdraw).not.toHaveBeenCalled();
+ await canvas.user.click(canvas.getByRole("button", { name: "Confirm withdrawal" }));
+ await expect(args.withdraw).toHaveBeenCalledWith("sub_1QxStudioFounding");
+ },
+};
+export const Withdrawing: Story = {
+ args: { billing: WITHDRAWABLE, busy: "withdraw" },
+ play: async ({ canvasElement }) => {
+ const canvas = await settledFrame(canvasElement);
+ await expect(canvas.getByRole("button", { name: "Withdraw from contract" })).toBeDisabled();
+ },
+};
+export const Withdrawn: Story = { args: { billing: WITHDRAWN } };
+/** Recorded, but its email and refund are still the cron's to finish. */
+export const WithdrawnPending: Story = {
+ args: { billing: { ...WITHDRAWN, withdrawn: { ...WITHDRAWN.withdrawn!, acknowledged: false, settled: false } } },
+};
+export const WithdrawablePhone: Story = { args: { billing: WITHDRAWABLE, phone: true } };
diff --git a/hosted/src/stories/fixtures.tsx b/hosted/src/stories/fixtures.tsx
index c1fc242cf..936a2b45b 100644
--- a/hosted/src/stories/fixtures.tsx
+++ b/hosted/src/stories/fixtures.tsx
@@ -24,7 +24,7 @@ export const ACCOUNT = { session: SESSION, computers: COMPUTERS, passkeys: PASSK
/** 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,
+ founding: { cohort: 0, seatsLeft: 63 }, founder: { offered: false, name: null }, consent: null, withdrawal: null, withdrawn: null,
};
/** A founding plan bought today, renewing next year. */
export const FOUNDING: BillingSummary = {
@@ -32,6 +32,22 @@ export const FOUNDING: BillingSummary = {
founder: { offered: true, name: null },
consent: { termsVersion: TERMS_VERSION, consentedAt: "2026-10-21T12:00:00Z" },
};
+/** The founding plan, bought in the EEA or UK a week ago: withdrawable online. */
+export const WITHDRAWABLE: BillingSummary = {
+ ...FOUNDING,
+ withdrawal: {
+ subscription: "sub_1QxStudioFounding", startedAt: "2026-10-21T12:00:00Z", until: "2026-11-11T12:00:00Z",
+ email: "alex@example.com",
+ },
+};
+/** Withdrawn from that plan: the acknowledgement the billing page keeps. */
+export const WITHDRAWN: BillingSummary = {
+ ...NO_PLAN,
+ withdrawn: {
+ reference: "4f6c1d2e-8a3b-4c5d-9e0f-1a2b3c4d5e6f", subscription: "sub_1QxStudioFounding", plan: "founding",
+ startedAt: "2026-10-21T12:00:00Z", submittedAt: "2026-10-28T09:30:00Z", acknowledged: true, settled: true,
+ },
+};
export type HostedStoryArgs = Partial & { phone?: boolean; light?: boolean };
const idle = async () => {};
const nothing = () => {};
From 88a16958280ea03a02c9571fd591c7486adfc4a6 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 06:04:54 -0700
Subject: [PATCH 3/5] Terms, Privacy, and specs: withdrawal, account closure,
and the support paths
The Terms' withdrawal section covers every subscription contract, describes
the online function as built, and takes the model form from the shared
constant; account closure is self-service and cancels the subscription at
once. Privacy records withdrawals and closures, Postmark's acknowledgements,
and self-service deletion. pricing.md promotes the withdrawal and closure
gates above the fold; hosted.md documents the withdraw route and Account
closure; security-hosted gains FAIL IF S288 (withdrawal) and S289 (closure).
Co-Authored-By: Claude Opus 5.5
---
.github/audit/hosted.md | 2 +-
docs/specs/hosted.md | 21 ++++++++++++++++++---
docs/specs/pricing.md | 13 +++++++++----
docs/specs/pricing.rationale.md | 8 ++++++--
docs/specs/security-hosted.md | 4 +++-
hosted/README.md | 2 +-
scripts/security-rule-ids.json | 2 +-
scripts/spec-word-budgets.json | 6 +++---
website/src/pages/Privacy.tsx | 9 +++++----
website/src/pages/Terms.tsx | 9 +++++----
10 files changed, 52 insertions(+), 24 deletions(-)
diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md
index 5cc64312d..33500b6ee 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; and a checkout URL answered before its consent row, or a founders row for a login without a current founding subscription.
+- **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. Trace a withdrawal and an account closure too: a withdrawal answered before its row, refused to an eligible contract, or stamped done before its email, cancellation, or refunds; and a closure that deletes an account whose subscription Stripe still renews.
- **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 3e79edb0a..de16d8b3f 100644
--- a/docs/specs/hosted.md
+++ b/docs/specs/hosted.md
@@ -80,11 +80,12 @@ The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` ->
| Route | Credential | Success |
|---|---|---|
-| `GET /api/billing` | login cookie | 200 `{ plan, price, active, until, renews, entitled, suspended, founding, founder, consent }`; `plan` names a subscription Stripe still holds |
+| `GET /api/billing` | login cookie | 200 `{ plan, price, active, until, renews, entitled, suspended, founding, founder, consent, withdrawal, withdrawn }`; `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/withdraw` | login cookie, exact `Origin`, JSON `{ subscription }` | 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.
@@ -96,12 +97,26 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401
- **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 offer the online withdrawal only from the held subscription while `withdrawalOffer` holds**, once per subscription; else withdraw answers 409 `WITHDRAWAL_CLOSED`. A recorded withdrawal (`dormouse_withdrawals`) is then acknowledged by email to the Stripe customer's address and settled: its subscription ended at once (`ENDED_FOR_WITHDRAWAL`), every paid invoice payment refunded in full. Each step is stamped once done and safe to repeat; the cron retries a failed one for 30 days. Checks: `docs/specs/security-hosted.md` -> "Account boundary".
- **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.
- **Must offer founding at the first ladder step whose completed purchases, refunds included, are short of `FOUNDING_COHORT_SIZE`**; its seats left exclude purchases ended within `REFUND_DAYS`. Past the last step, founding checkout answers 409.
-- **Must resync, every 10 minutes from the account's Cron Trigger, a bounded random batch of owners whose `active` or `trialing` row no longer grants access**, so a missed renewal webhook lapses a member only until a run picks them; a failure fails the invocation after every owner was tried.
+- **Must resync, every 10 minutes from the account's Cron Trigger, a bounded random batch of owners whose `active` or `trialing` row no longer grants access**, so a missed renewal webhook lapses a member only until a run picks them, and finish a bounded batch of unfinished withdrawals; a failure fails the invocation after every one was tried.
- **Must grant the account's role the `pgstencil_billing` schema from `runtime-roles.sql`**, additively: the package's migration creates it.
-Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`; `billingRoutes` and `disputeAction` in `hosted/server/billing-routes.ts`. Pinned by `hosted/server/tests/billing.test.ts` and `hosted/server/tests/billing-routes.test.ts`, on StripeDev.
+Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`; `billingRoutes` and `disputeAction` in `hosted/server/billing-routes.ts`; `withdrawalOffer` and `completeWithdrawal` in `hosted/server/withdrawal.ts`. Pinned by `hosted/server/tests/billing.test.ts` and `hosted/server/tests/billing-routes.test.ts`, on StripeDev.
+
+## Account closure
+
+| Route | Credential | Success |
+|---|---|---|
+| `POST /api/account/close` | login cookie, exact `Origin`, JSON `{ close: true }` | 204, the account deleted |
+
+Errors are JSON `{ message }`; it needs no entitlement and works with billing closed. What the page says first: `docs/specs/pricing.md` -> "Checkout and entitlement".
+
+- **Must refuse a login older than `LOGIN_FRESH_AGE_MS`** with 403 `CLOSE_RECENT_LOGIN`, and, billing closed, an account with a current subscription row with 503 `CLOSE_UNAVAILABLE`.
+- **Must expire an open checkout, then cancel every current subscription in Stripe (`ENDED_FOR_CLOSURE`), before one statement records the closure (`dormouse_account_closures`) and deletes the `"user"` row**; rows without a foreign key stay. Its Burrows' sockets then close as a removal's do ("Relay sockets"). Checks: `docs/specs/security-hosted.md` -> "Account boundary".
+
+Source of truth: `closureRoutes` in `hosted/server/closure.ts`; `CloseAccount` in `hosted/src/Closure.tsx`.
## Managed voice
diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md
index 0a8a65f43..5b2196387 100644
--- a/docs/specs/pricing.md
+++ b/docs/specs/pricing.md
@@ -62,8 +62,14 @@ Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; the `/pricing` rule
- **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.
+- **Must support EEA and UK consumer withdrawal**; the voluntary guarantee is additional (rationale):
+ - **Must disclose the statutory withdrawal right, where the online withdrawal is, the support path, and the model form at checkout and in the `/billing` confirmation.**
+ - **Must offer an online withdrawal function on `/billing` throughout the statutory period, distinct from cancelling renewal**: it names the contract and where the acknowledgement goes, takes a confirmation, and gives a durable acknowledgement with the statement and its date and time, on the page and by email (rationale).
+ - **Must end a withdrawn contract at once and refund every payment for it in full, tax included, within 14 days**, with no use deduction or waiver for immediate access. An unambiguous notice by email or post needs no account access; support refunds it in Stripe, which ends it as a full refund does.
+- **Must let a member cancel without account recovery**: in the customer portal, or by email to support when access is lost.
+- **Closing an account cancels its subscription at once** (`docs/specs/hosted.md` -> "Account closure"). **Must say before closing** that access ends now and renewal stops, that the time left is not refunded, which refunds and withdrawal stay open through support, and that cancelling renewal keeps access instead.
-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.
+Source of truth: `withBilling` and `openCohort` in `hosted/server/billing.ts`; `Checkout` in `hosted/src/Checkout.tsx`; `PlanConfirmation` in `hosted/src/Plan.tsx`; `WithdrawalFunction` in `hosted/src/Withdrawal.tsx`; `closureTerms` in `hosted/src/Closure.tsx`; `docs/specs/hosted.md` -> "Billing" owns the rest.
## Future
@@ -128,14 +134,13 @@ 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 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 turn on cancellation in Stripe's customer portal before charging**, the online cancellation "Checkout and entitlement" relies on.
- **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.
- **Must notify the customer of a payment-dispute suspension** and preserve refund and dispute rights; suspension, its restoration, and final reversal are "Checkout and entitlement".
- **Must refund unused prepaid service, including corresponding collected tax, on permanent discontinuation or termination unrelated to customer breach**, and offer the same remedy for a material service reduction or rejected material terms change during a prepaid term. **Must give at least 30 days' advance notice of discontinuation, material reductions, or material terms changes**, except urgent legal or security requirements; no retroactive terms changes for disputes.
- **Must verify the permitted sales territories and tax setup before accepting payment**, and confirm refund, cancellation, failed-payment, and founder-lock behavior against the published offer. Stripe Billing does not transfer the seller's tax obligations.
-- **Must support worldwide sales only where lawful, including EEA and UK consumer rights**: disclose the statutory withdrawal right and model form before purchase and in the confirmation, accept an unambiguous notice without account recovery, and refund withdrawal payments within 14 days without a use deduction or waiver for immediate access. The voluntary guarantee is additional (rationale).
-- **Must provide a prominent online withdrawal function on the account billing page throughout the statutory withdrawal period**, distinct from cancelling renewal; allow the consumer to identify the contract, confirm submission, and receive a durable acknowledgement with the statement and its date and time. **Must disclose its location before purchase and in the confirmation** (rationale).
+- **Must support worldwide sales only where lawful**; EEA and UK withdrawal is "Checkout and entitlement".
- **Must complete the EEA and UK provider privacy arrangements before launch**: execute applicable processor agreements, document provider transfer safeguards and assessments, and publish the applicable contacts and means to obtain the safeguards. **Never claim a transfer certification, contract, or representative that has not been verified** (rationale).
- **Must recheck ElevenLabs' training opt-out or contractual no-training protection and the applicable processing agreement before enabling paid voices**, and keep the public disclosure consistent with the verified practice. History deletion alone is not evidence of either protection (rationale).
- **Must obtain acceptance of the managed-voice customer provisions before granting paid voice access**, archive the incorporated provider requirements with the accepted terms, preserve mandatory consumer rights, and relay relevant provider notices. **Must apply the terms' age and government-use restrictions to managed voices** and verify the permitted voice selection. **Must obtain ElevenLabs' written approval before marketing that names it**; the Terms and Privacy policy name it because the provider requirements and disclosure law need them to. Provider requirement changes follow the published notice, renewed-agreement, service-reduction, and refund process (rationale).
diff --git a/docs/specs/pricing.rationale.md b/docs/specs/pricing.rationale.md
index 9b41f6854..a7eb65be1 100644
--- a/docs/specs/pricing.rationale.md
+++ b/docs/specs/pricing.rationale.md
@@ -1,12 +1,16 @@
# Pricing rationale
+## Checkout and entitlement
+
+- The worldwide launch decision includes EEA and UK consumers. The [EU distance-selling guidance](https://europa.eu/youreurope/business/selling-in-eu/selling-goods-services/ecommerce-distance-selling/index_en.htm) distinguishes statutory withdrawal disclosures and reimbursement timing from a voluntary refund promise. No use deduction or waiver is needed to support the offered full-refund policy (reviewed 2026-10-07).
+- [Directive 2023/2673](https://eur-lex.europa.eu/eli/dir/2023/2673/oj/eng) added the Consumer Rights Directive's Article 11a online withdrawal function, with national measures applying from 2026-06-19. Despite the amending directive's financial-services title, this function also covers other distance contracts concluded through an online interface (reviewed 2026-10-07).
+- The online function is offered per Stripe subscription, each a new contract, for three weeks from its start: the statutory period ends at the end of the 14th day after the contract in the consumer's time zone, later when that falls on a weekend or holiday (Regulation 1182/71), so a fixed 14 × 24 hours could close it early. Eligibility reads the billing country Stripe Tax saved; an unknown country or an unreachable Stripe offers it, as do the Crown Dependencies and Gibraltar, where the doubt was resolved toward offering.
+
## Paid-launch requirements
- The September 29 account terms promised reasonable advance notice of material changes. The October 8 revision adds a liability cap, business indemnity, and forum clause, so publication is distinguished from acceptance and the notified transition for existing accounts. Its 30-day transition is the chosen contractual process, not a claim that every jurisdiction requires that exact period. Subscription notices use the checkout/billing email because provider-only accounts can lack a sign-in email; an undisplayed account notice is not treated as delivered.
- California notice windows were checked against the [Attorney General's September 2025 guidance](https://oag.ca.gov/node/608083) on 2026-10-07. The applicable sales jurisdictions need review before launch; a generic promise of enough time to cancel does not configure the required notices.
- ElevenLabs' [retention documentation](https://elevenlabs.io/docs/eleven-api/resources/zero-retention-mode) and [model-training guidance](https://elevenlabs.io/docs/help-center/legal/is-my-data-used-to-improve-eleven-labs-ai-models), checked 2026-10-07, distinguish visible history deletion from residual records, backups, and training settings. On 2026-10-07 the workspace training opt-out was saved in the system browser and verified off after a full reload. The Dormouse Hosted service-account key was restricted to Text to Speech and History write, with a 10,000-credit refresh-period limit and leak auto-disable enabled. The operator separately confirmed history isolation. These facts do not establish zero retention or retroactively undo training; section 4(i) of the [provider terms](https://elevenlabs.io/terms-of-use) makes the opt-out prospective after processing.
-- The worldwide launch decision includes EEA and UK consumers. The [EU distance-selling guidance](https://europa.eu/youreurope/business/selling-in-eu/selling-goods-services/ecommerce-distance-selling/index_en.htm) distinguishes statutory withdrawal disclosures and reimbursement timing from a voluntary refund promise. No use deduction or waiver is needed to support the offered full-refund policy (reviewed 2026-10-07).
-- [Directive 2023/2673](https://eur-lex.europa.eu/eli/dir/2023/2673/oj/eng) added the Consumer Rights Directive's Article 11a online withdrawal function, with national measures applying from 2026-06-19. Despite the amending directive's financial-services title, this function also covers other distance contracts concluded through an online interface (reviewed 2026-10-07).
- The [ICO's representative guidance](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/international-transfers/receiving-personal-information-from-the-eea/) describes separate EEA and UK representation duties and the narrow occasional-processing exception; [its transfer guidance](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/international-transfers/a-brief-guide-to-international-transfers/) distinguishes adequacy from other safeguards. Provider locations alone do not establish either arrangement (reviewed 2026-10-07).
- On 2026-10-08 the operator declined to appoint EEA or UK representatives at this stage and accepted the resulting compliance risk. Appointment is therefore excluded from the internal launch gate; this records a business decision, not a legal exemption. The published privacy contact is DiffPlug's support address and does not purport to be a local representative.
- The [OEM terms](https://elevenlabs.io/oem-terms), checked 2026-10-07, define eligible business customers as Scale, Business, Enterprise, or equivalent subscribers (1.D), but define end users by internal business operations (1.I). The [Grant program](https://elevenlabs.io/startup-grants) advertises Scale access for startups building and launching products, supporting plan eligibility. The operator elected to proceed without vendor negotiation and treat the personal/hobby-use wording as a known supplier-contract ambiguity; grant-expiry planning is outside this review. This decision does not amend ElevenLabs' agreement. Section 2.B also requires approval for government use and public statements about the supplier relationship; adding customer clauses does not supply such approval.
diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md
index 506f80e4b..e548a5b12 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, 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` |
+| **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. An EEA or UK withdrawal is recorded before it is answered and refunded in full, and closing an account cancels its subscription before deleting 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`.
@@ -36,6 +36,8 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t
- **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** `S288` withdraw answers 2xx before its `dormouse_withdrawals` row, naming the login, the subscription, and the submission time, has committed; records one for a subscription that is not the login's held one or outside `withdrawalOffer`; refuses the online withdrawal within `WITHDRAWAL_OPEN_DAYS` to a held subscription whose Stripe billing country is in `WITHDRAWAL_COUNTRIES` or unknown; marks it acknowledged before its email was sent or settled before its subscription ended and every paid invoice payment was refunded in full; lets a failed step go unretried by the cron; or that table cascades from or is deleted with the account. Inspect the withdraw route in `hosted/server/billing-routes.ts`, `hosted/server/withdrawal.ts`, `resyncBilling` in `hosted/server/billing.ts`, and `hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql`.
+- **FAIL IF** `S289` account closure deletes the `"user"` row while any subscription of its owner Stripe holds is current and not yet canceled in Stripe, or with billing closed while a current subscription row exists; acts for any but the cookie's login or one older than `LOGIN_FRESH_AGE_MS`; or deletes or cascades the billing records, checkout consents, withdrawals, or its closure record. Inspect `closureRoutes` in `hosted/server/closure.ts` and `hosted/server/dormouse-migrations/017_withdrawals_and_closures.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 520aa52e9..2760dea46 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. Before opening checkout, turn on Stripe's customer emails for successful payments: the `/billing` confirmation tells a buyer Stripe sends each receipt.
+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. Turn on cancellation in the customer portal too: the confirmation sends a member to Manage billing to cancel. The account Worker emails withdrawal acknowledgements through the same Postmark server as sign-in codes.
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/scripts/security-rule-ids.json b/scripts/security-rule-ids.json
index e51615e29..e20e5646e 100644
--- a/scripts/security-rule-ids.json
+++ b/scripts/security-rule-ids.json
@@ -1,5 +1,5 @@
{
- "next": 288,
+ "next": 290,
"retired": [
265
]
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index 8776459ad..b6145fda6 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": 5650,
+ "docs/specs/hosted.md": 5850,
"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": 2600,
+ "docs/specs/pricing.md": 2700,
"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": 3700,
+ "docs/specs/security-hosted.md": 3950,
"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 f620f177b..4ba9323c0 100644
--- a/website/src/pages/Privacy.tsx
+++ b/website/src/pages/Privacy.tsx
@@ -32,6 +32,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [
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.
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 withdraw from a subscription on your billing page, we record which subscription, its plan and price, your billing country as Stripe holds it, when you submitted the withdrawal, and when we emailed its acknowledgement and completed the refund. To decide whether to offer the withdrawal, we read your billing country and email from Stripe. If you close your account, we record when, and which subscriptions closing canceled.
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 need account and billing information to provide the corresponding services; without it we cannot create the account or fulfill the purchase. Public founder attribution and survey answers are optional.
Cloudflare runs the account, Relay, and voice services. Neon stores the database. Postmark delivers sign-in emails and processes their recipients, contents, and delivery records. GitHub stores encrypted database-backup artifacts. Stripe processes payments and manages billing. ElevenLabs generates Natural Voice speech from spoken labels. PostHog counts visits to this website, as described above, under PostHog’s privacy policy. These providers process information needed to provide their services; Stripe’s and ElevenLabs’ additional processing is described above.
+
Cloudflare runs the account, Relay, and voice services. Neon stores the database. Postmark delivers sign-in emails and withdrawal acknowledgements and processes their recipients, contents, and delivery records. GitHub stores encrypted database-backup artifacts. Stripe processes payments and manages billing. ElevenLabs generates Natural Voice speech from spoken labels. PostHog counts visits to this website, as described above, under PostHog’s privacy policy. These providers process information needed to provide their services; Stripe’s and ElevenLabs’ additional processing is described above.
Push notifications travel through the push service your phone’s browser uses — Apple, Google, Mozilla, or Microsoft — which sees that a sealed notification was sent, not what it says.
A sign-in provider you choose learns that you are authenticating with Dormouse and handles that interaction under its own policy. Our email and support providers also process messages you send us. Authorized DiffPlug personnel may access information to operate the service or respond to your request.
We may disclose information when legally required, to investigate abuse, or to protect people and the service. If a business transfer affects your information, we will notify you of material changes to how it is handled.
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, 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 keep subscription and billing-event records, including what you agreed to at checkout, withdrawals, and account closures, 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.
You choose which sign-in providers to use. Connecting another provider requires a recent login and an explicit action in your account. Logging out ends the current browser’s login; it does not log out your other devices. You can also remove Dormouse’s authorization in a provider’s settings, but that alone does not delete your Dormouse account.
-
At any time, you can turn Natural Voice off on one computer in Dormouse or on every computer from your account page, turn push notifications off in Dormouse, remove an enrolled computer from your account, leave the founders row, and cancel your subscription from the billing portal.
-
Contact support@dormouse.sh to request access, correction, export, or deletion of account information, or to raise a privacy concern. We may need to verify your identity before acting. We handle requests according to applicable law, including any rights to object, restrict processing, or complain to a data-protection authority. Account deletion is handled through support; there is no self-service deletion control yet.
+
At any time, you can turn Natural Voice off on one computer in Dormouse or on every computer from your account page, turn push notifications off in Dormouse, remove an enrolled computer from your account, leave the founders row, cancel your subscription from the billing portal, and close your account from your account page.
+
Contact support@dormouse.sh to request access, correction, export, or deletion of account information, or to raise a privacy concern. We may need to verify your identity before acting. We handle requests according to applicable law, including any rights to object, restrict processing, or complain to a data-protection authority. Closing your account from your account page deletes it, with its sign-in methods, computers, passkeys, push subscriptions, and Natural Voice credentials; the billing records described above remain. If you cannot sign in, ask support to close it.
If the EU or UK GDPR applies, you may request access, correction, erasure, restriction, or portability where the relevant conditions apply, and object to processing based on legitimate interests because of your particular situation. You may withdraw consent without affecting prior lawful processing. We respond without undue delay, normally within one month; if the law permits more time for a complex request, we will explain the extension within that month. You may complain to your local data-protection authority in the EEA or the UK Information Commissioner, without first contacting us.
> },
{ id: "updates", title: "Changes to this policy", body: <>
diff --git a/website/src/pages/Terms.tsx b/website/src/pages/Terms.tsx
index b0c3aa821..d970ec726 100644
--- a/website/src/pages/Terms.tsx
+++ b/website/src/pages/Terms.tsx
@@ -2,6 +2,7 @@ import type { MetaArgs } from "react-router";
import HostedPolicyLayout, { type PolicySection } from "../components/HostedPolicyLayout";
import { LINK_CLASS } from "dormouse-lib/site/docs-tokens";
import { siteMeta, sitePath } from "../lib/site-meta";
+import { MODEL_WITHDRAWAL_FORM } from "../lib/hosted-withdrawal";
export function meta({ location }: MetaArgs) {
return siteMeta(location.pathname, {
@@ -36,10 +37,10 @@ export const TERMS_SECTIONS: PolicySection[] = [
This guarantee is in addition to the unused-period refunds below and any rights or remedies the law where you live gives you, including cancellation, withdrawal, and remedies for defective or undelivered services. Those rights are not limited to 30 days by these terms. We issue refunds to the original payment method where possible; your bank or payment provider controls when the credit appears.
> },
{ id: "withdrawal", title: "EEA and UK consumer withdrawal", body: <>
-
If you are a consumer in the European Economic Area or United Kingdom, you may withdraw from your initial Hosted subscription contract without giving a reason within 14 days after the contract is made. Send an unambiguous statement to support@dormouse.sh or by post to DiffPlug LLC, 447 Sutter St Ste 405, San Francisco, CA 94108, United States, before that period ends. We do not require a particular form, account access, or a reason.
-
You can also use “Withdraw from contract” on your Hosted account’s billing page during the withdrawal period, then confirm your withdrawal. We will send you an acknowledgement on a durable medium, such as email, with your statement and the date and time it was submitted. Withdrawal ends the current contract; ordinary cancellation of renewal keeps access through the paid period.
+
If you are a consumer in the European Economic Area or United Kingdom, you may withdraw from a Hosted subscription contract without giving a reason within 14 days after the contract is made. An automatic renewal does not start a new withdrawal period. Send an unambiguous statement to support@dormouse.sh or by post to DiffPlug LLC, 447 Sutter St Ste 405, San Francisco, CA 94108, United States, before that period ends. We do not require a particular form, account access, or a reason.
+
You can also use “Withdraw from contract” on your Hosted account’s billing page, which stays available for at least the withdrawal period. It shows the contract it applies to and the email address the acknowledgement goes to; you then select “Confirm withdrawal”. We show an acknowledgement with your statement and the date and time it was submitted, keep it on your billing page, and email it to your billing email. Withdrawal ends the current contract straight away; ordinary cancellation of renewal keeps access through the paid period. If you cannot sign in, email us instead.
For that withdrawal, we will refund all payments received for the subscription, including collected tax, without undue delay and within 14 days after receiving your notice. We use the original payment method unless you expressly agree otherwise, and charge no refund fee. Access ends on withdrawal. Starting to use Hosted immediately does not waive this right, and we do not deduct a charge for use during this withdrawal period. Our separate 30-day guarantee and any longer statutory rights remain available.
-
You may use this optional withdrawal form by email or post: “To DiffPlug LLC, 447 Sutter St Ste 405, San Francisco, CA 94108, United States; support@dormouse.sh: I give notice that I withdraw from my contract for Dormouse Hosted. Ordered on: ____. Name: ____. Address: ____. Date: ____. Signature, only if sent on paper: ____.” Your account email or order reference helps us locate the purchase, but is not a required formality for giving notice.
+
You may use this optional withdrawal form by email or post: “{MODEL_WITHDRAWAL_FORM}” Your account email or order reference helps us locate the purchase, but is not a required formality for giving notice.
A founding subscription is a yearly plan sold at a launch price, in cohorts of 100. When a cohort sells out the founding price rises, and founding closes when it reaches the list price.
We will give at least 30 days’ advance notice before we permanently discontinue Hosted, materially reduce your paid service during a prepaid term, or make a material change to these terms, unless an urgent legal or security requirement prevents it; in that case we will notify you as soon as reasonably possible.
We refund the unused portion of your prepaid subscription, including the corresponding tax we collected, if we discontinue Hosted, if we end your paid access for a reason other than your material breach, or if, during a prepaid term, we materially reduce your paid service or materially change these terms and you choose to end your subscription instead of accepting the change. The refund runs from when paid access ends, and the 30-day guarantee does not limit it. Discontinuation also stops renewals. The Relay is source-available and its self-hosting guide is published if you want to operate your own Relay; self-hosting does not replace your refund.
We may restrict, suspend, or terminate an account to address abuse, a security issue, a legal requirement, or a material breach of these terms. Where practical, we will explain the reason and provide a way to contact us; urgent protective action may come first.
-
You may stop using the service at any time. Contact support@dormouse.sh to request account closure or to ask us to review a restriction. When we close your account, we also cancel future subscription renewals. Before completing your closure request, we will explain when paid access will end and any refund available to you; you can instead cancel renewal and keep access through the paid period. Information is retained or deleted as described in the privacy policy and as required by law.
+
You may stop using the service at any time. You can close your account with “Close account” on your account page, or, if you cannot sign in, by emailing support@dormouse.sh; we may verify that you own the account, but do not require you to recover access to it. Closing cancels your subscription at once: paid access ends and no renewal follows. Before you confirm, we explain when paid access will end and which refunds remain available to you. Closing does not by itself refund the rest of a paid period, but it does not end the 30-day guarantee or a withdrawal right, which you can still use by emailing us. You can instead cancel renewal and keep access through the paid period. Email us to ask us to review a restriction. Information is retained or deleted as described in the privacy policy and as required by law.
Consumers retain their mandatory rights to services that conform to the contract and are provided with reasonable care and skill, and any applicable remedies such as correction, a price reduction, or a refund. The qualifications below apply only where the law permits them.
From 46a5d63a18cf15aed51d92b82ccbecde35424876 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 06:17:18 -0700
Subject: [PATCH 4/5] Hosted: show a withdrawal's acknowledgement only for its
own plan
GET /api/billing returned the account's latest withdrawal whatever it now
held, so after withdrawing and buying again /billing still led with
"Withdrawal received" above the new plan. It now returns it only while
nothing is held or it is the held subscription's, its settlement pending.
Co-Authored-By: Claude Opus 5.5
---
hosted/server/billing-routes.ts | 7 ++++++-
hosted/server/tests/billing-routes.test.ts | 3 +++
hosted/src/billing.ts | 5 ++++-
3 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts
index 73bc340e6..70bd290c7 100644
--- a/hosted/server/billing-routes.ts
+++ b/hosted/server/billing-routes.ts
@@ -141,7 +141,12 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
founder: { offered: account?.founder === true, name: account?.name ?? null },
consent: held ? (account?.consent ?? null) : null,
withdrawal: offer && { subscription: offer.subscription, startedAt: offer.startedAt, until: offer.until, email: offer.email },
- withdrawn: account?.withdrawn ?? null,
+ // Only while it describes the held subscription, its settlement still
+ // pending, or nothing is held: a plan bought after it is not withdrawn.
+ withdrawn:
+ account?.withdrawn && (!held || account.withdrawn.subscription === status.subscription!.id)
+ ? account.withdrawn
+ : null,
});
};
diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts
index 16b149a12..d619fc0d7 100644
--- a/hosted/server/tests/billing-routes.test.ts
+++ b/hosted/server/tests/billing-routes.test.ts
@@ -719,6 +719,9 @@ test("an EEA or UK consumer withdraws online: recorded, acknowledged by email, r
await f.deliver();
expect((await withdraw(subscription.id)).status).toBe(200);
expect([f.cancels.length, f.refunds.length, f.email.all().length]).toEqual([1, 2, 1]);
+ // A plan bought after it is not withdrawn: the acknowledgement gives way to it.
+ await f.buy(member, "monthly");
+ expect(await member.summary()).toMatchObject({ plan: "monthly", withdrawn: null });
// A billing record: it outlives the account.
await f.sql(`DELETE FROM "user" WHERE id = $1`, [member.id]);
expect(await f.sql(`SELECT id FROM dormouse_withdrawals`)).toEqual([{ id: row!.id }]);
diff --git a/hosted/src/billing.ts b/hosted/src/billing.ts
index bb149380a..83754daac 100644
--- a/hosted/src/billing.ts
+++ b/hosted/src/billing.ts
@@ -32,7 +32,10 @@ export interface BillingSummary {
consent: { termsVersion: string; consentedAt: string } | null;
/** The EEA or UK online withdrawal from the held subscription, while it is open. */
withdrawal: WithdrawalOffer | null;
- /** The account's latest withdrawal, kept on the page as its acknowledgement. */
+ /**
+ * The account's latest withdrawal, kept on the page as its acknowledgement:
+ * the server sends it only while no plan is held or it is the held plan's.
+ */
withdrawn: Withdrawn | null;
}
From 68a205a12e593436809be37fdf3542c4246bd23c Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Wed, 28 Oct 2026 17:13:11 -0700
Subject: [PATCH 5/5] Hosted tests: one BillingSetup import after the merge
Co-Authored-By: Claude Opus 5.5
---
hosted/server/tests/billing-routes.test.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts
index 14832cfb9..ce3dc980c 100644
--- a/hosted/server/tests/billing-routes.test.ts
+++ b/hosted/server/tests/billing-routes.test.ts
@@ -8,7 +8,6 @@ import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER, LIST_ANNUAL, LIST_MONTHLY } from
import { WITHDRAWAL_STATEMENT } from "../../../website/src/lib/hosted-withdrawal";
import { resyncBilling, type BillingSetup } from "../billing";
import { MAX_SHOWN_FOUNDERS } from "../../../website/src/lib/hosted-cohorts";
-import type { BillingSetup } from "../billing";
import {
BILLING_WEBHOOK_PATH,
CHECKOUT_CLOSED,