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 +