From 4987abeea9be3d6f763a26b402983964438e614d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 06:27:12 -0700 Subject: [PATCH 1/3] Hosted: renewal, annual, price-change, and dispute notices The account cron records and sends each subscription notice once, to the Stripe billing email: a yearly renewal notice 30 days ahead, a monthly plan's annual reminder before each renewal that ends a year, and a price change's notice 21 days before the renewal it applies at. An operator's dormouse_price_changes row moves a subscriber to the new Price only after their notice was sent at least 7 days before that renewal, never from a founding Price, and only once Stripe's Prices match the recorded amounts. Opening a dispute records its suspension notice, and closing it in the account's favor its restoration notice, in the webhook's transaction; the send follows the commit and the cron retries one that failed. Co-Authored-By: Claude Opus 5.5 --- hosted/server/billing-dev.ts | 13 +- hosted/server/billing-routes.ts | 91 ++-- hosted/server/billing.ts | 46 +- .../018_billing_notices.sql | 59 +++ hosted/server/notices.ts | 409 ++++++++++++++++++ hosted/server/tests/billing-routes.test.ts | 174 +++++++- hosted/server/tests/migrations.test.ts | 2 + hosted/server/tests/stripe-fake.ts | 68 ++- hosted/server/withdrawal.ts | 5 +- 9 files changed, 815 insertions(+), 52 deletions(-) create mode 100644 hosted/server/dormouse-migrations/018_billing_notices.sql create mode 100644 hosted/server/notices.ts diff --git a/hosted/server/billing-dev.ts b/hosted/server/billing-dev.ts index c43788180..15275e24d 100644 --- a/hosted/server/billing-dev.ts +++ b/hosted/server/billing-dev.ts @@ -9,12 +9,17 @@ export const DEV_FOUNDING_PRICES = FOUNDING_LADDER.map((price) => `price_foundin /** * StripeDev, a local Stripe stand-in that takes no card and charges nothing, - * with the founding ladder's yearly Prices, and the billing setup that runs - * on it. + * with the founding ladder's yearly Prices and any `recurring` beside them, + * and the billing setup that runs on it. */ -export async function stripeDevBilling(time: Time, random: RandomSource, statePath?: string) { +export async function stripeDevBilling( + time: Time, + random: RandomSource, + statePath?: string, + recurring: Record = {}, +) { const dev = await createStripeDev(time, random, statePath, { - recurring: Object.fromEntries(DEV_FOUNDING_PRICES.map((price) => [price, "year" as const])), + recurring: { ...Object.fromEntries(DEV_FOUNDING_PRICES.map((price) => [price, "year" as const])), ...recurring }, }); const setup: BillingSetup = { secretKey: "sk_test_dormouse_local_only", diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts index 70bd290c7..0a5344b54 100644 --- a/hosted/server/billing-routes.ts +++ b/hosted/server/billing-routes.ts @@ -11,6 +11,7 @@ import { CURRENT_STATUSES, ENDED_STATUSES, entitledSql, subscribedSql } from "./ 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 { priceUsd, sendNotices, type NoticeContext, type PriceChange } from "./notices"; import { completeWithdrawal, recordWithdrawal, recordedWithdrawal, withdrawalOffer } from "./withdrawal"; import { SUPPORT_EMAIL } from "../../website/src/lib/hosted-withdrawal"; import type { EmailSender } from "pgstencil"; @@ -21,7 +22,7 @@ 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. */ + /** The account's mail, which a withdrawal's acknowledgement and a dispute's notices go out by. */ mail(): EmailSender; } @@ -75,13 +76,16 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) bodyLimit({ maxSize, onError: (c) => c.json({ message: "Request too large." }, 413) }); /** Runs `action` on this deployment's `Billing`: 503 while closed, a `BillingError` as its status. */ - const billed = async (c: Context, action: (billing: Billing, setup: BillingSetup) => Promise) => { + const billed = async ( + c: Context, + action: (billing: Billing, setup: BillingSetup, changes: readonly PriceChange[]) => Promise, + ) => { const deployment = host(c); const setup = deployment.setup(); if (!setup) return c.json({ message: CHECKOUT_CLOSED }, 503); try { - return await withBilling(setup, deployment.databaseUrl, new URL(c.req.url).origin, (billing) => - action(billing, setup), + return await withBilling(setup, deployment.databaseUrl, new URL(c.req.url).origin, (billing, changes) => + action(billing, setup, changes), ); } catch (error) { if (error instanceof BillingError) return c.json({ message: error.message }, error.status as 400); @@ -90,7 +94,13 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) }; /** The account's plan, from the synchronized rows the webhook, confirm, and resync keep current. */ - const summary = async (c: Context, billing: Billing, setup: BillingSetup, userId: string) => { + const summary = async ( + c: Context, + billing: Billing, + setup: BillingSetup, + changes: readonly PriceChange[], + userId: string, + ) => { const [status, [account], founding] = await Promise.all([ billing.status(userId), accountQuery<{ @@ -130,7 +140,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) : null; return c.json({ plan: held ? status.plan : null, - price: held ? publishedPrice(setup, status.subscription!.price_id) : null, + price: held ? priceUsd(setup, changes, status.subscription!.price_id) : null, active: status.access, until: held ? status.accessUntil : null, renews: held && !status.subscription!.cancel_at_period_end, @@ -151,7 +161,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) }; app.get("/api/billing", gate, (c) => - billed(c, (billing, setup) => summary(c, billing, setup, c.get("login").userId)), + billed(c, (billing, setup, changes) => summary(c, billing, setup, changes, c.get("login").userId)), ); app.post("/api/billing/checkout", limit(JSON_BODY_BYTES), gate, async (c) => { @@ -206,9 +216,9 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) const checkout = (await readJson<{ checkout?: unknown }>(c))?.checkout; if (typeof checkout !== "string" || !CHECKOUT_ID.test(checkout)) return c.json({ message: "Checkout not found." }, 404); - return billed(c, async (billing, setup) => { + return billed(c, async (billing, setup, changes) => { await billing.confirmCheckout(userId, checkout); - return summary(c, billing, setup, userId); + return summary(c, billing, setup, changes, userId); }); }); @@ -226,7 +236,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) 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) => { + return billed(c, async (billing, setup, changes) => { let row = await recordedWithdrawal(billing, userId, subscription); if (!row) { const { subscription: held, plan } = await billing.status(userId); @@ -237,7 +247,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) 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); + return summary(c, billing, setup, changes, userId); }); }); @@ -250,7 +260,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) const name = body?.name; if (typeof shown !== "boolean" || (shown && !isFounderName(name))) return c.json({ message: "Choose a name of 1 to 64 characters." }, 400); - return billed(c, async (billing, setup) => { + return billed(c, async (billing, setup, changes) => { if (!shown) await accountQuery(host(c), `DELETE FROM dormouse_founders WHERE "userId" = $1`, [userId]); else { const joined = await accountQuery( @@ -263,7 +273,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) ); if (!joined.length) return c.json({ message: FOUNDERS_ONLY }, 409); } - return summary(c, billing, setup, userId); + return summary(c, billing, setup, changes, userId); }); }); @@ -273,8 +283,8 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) const signature = c.req.header("stripe-signature"); if (!signature) return c.json({ message: "Missing signature." }, 400); const body = await c.req.text(); - return billed(c, async (billing) => { - await processEvent(billing, body, signature); + return billed(c, async (billing, setup, changes) => { + await processEvent(billing, body, signature, () => host(c).mail(), { setup, changes, origin: new URL(c.req.url).origin }); return c.json({ received: true }); }); }); @@ -294,11 +304,18 @@ async function ownerOf(billing: Billing, customer: string | Stripe.Custome /** * One verified event: pgstencil's synchronization, and in its transaction - * the counts and dispute suspensions, so a redelivery repeats neither. A full - * refund or a lost dispute then ends the subscription in Stripe, after the - * commit and on every delivery until it has. + * the counts, dispute suspensions, and their notices' records, so a + * redelivery repeats none. A full refund or a lost dispute then ends the + * subscription in Stripe, after the commit and on every delivery until it + * has; a dispute's notice is then sent, and the cron retries one that fails. */ -async function processEvent(billing: Billing, body: string, signature: string) { +async function processEvent( + billing: Billing, + body: string, + signature: string, + mail: () => EmailSender, + context: NoticeContext, +) { const event = await billing.verifyWebhook(body, signature); // A dispute names no customer, and its event may be stale: read its current // status and its charge from Stripe, before any write. @@ -329,11 +346,14 @@ async function processEvent(billing: Billing, body: string, signature: str plan, ); } - if (dispute?.owner) await recordDispute(trx, dispute.id, dispute.owner, dispute.action); + if (dispute?.owner) await recordDispute(trx, dispute.id, dispute.owner, dispute.action, billing.time.now()); }); const refunded = event.type === "charge.refunded" ? (event.data.object as Stripe.Charge) : null; const reversed = refunded?.refunded ? refunded : dispute?.action === "end" ? dispute.charge : null; if (reversed) await endForRefund(billing, reversed); + if (dispute?.owner) + for (const error of await sendNotices(billing, mail, context, { userId: dispute.owner })) + console.error("A dispute notice is recorded but not sent; the cron retries it", error); } /** @@ -357,24 +377,45 @@ async function subscriptionPaidBy(billing: Billing, charge: Stripe.Charge) return paid.size === 1 ? [...paid][0]! : null; } -/** Opens or closes dispute `disputeId`'s suspension of `owner`, as `action` says. */ +/** + * Opens or closes dispute `disputeId`'s suspension of `owner`, as `action` + * says, recording the notice it owes in the same statement: opening it, its + * suspension notice; closing it in the account's favor, its restoration + * notice, unless another open dispute still suspends the account. + */ async function recordDispute( trx: Kysely, disputeId: string, owner: string, action: ReturnType, + now: Date, ) { if (action === "suspend") await trx.executeQuery( CompiledQuery.raw( - `INSERT INTO public.dormouse_billing_disputes ("disputeId", "userId") VALUES ($1, $2) - ON CONFLICT ("disputeId") DO NOTHING`, - [disputeId, owner], + `WITH opened AS ( + INSERT INTO public.dormouse_billing_disputes ("disputeId", "userId") VALUES ($1, $2) + ON CONFLICT ("disputeId") DO NOTHING RETURNING "userId") + INSERT INTO public.dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") + SELECT gen_random_uuid()::text, "userId", 'dispute_suspended', $1, $3 FROM opened + ON CONFLICT DO NOTHING`, + [disputeId, owner, now], ), ); else if (action) + // The statement reads the table as it was before its own delete. await trx.executeQuery( - CompiledQuery.raw(`DELETE FROM public.dormouse_billing_disputes WHERE "disputeId" = $1`, [disputeId]), + CompiledQuery.raw( + `WITH closed AS ( + DELETE FROM public.dormouse_billing_disputes WHERE "disputeId" = $1 RETURNING "userId") + INSERT INTO public.dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") + SELECT gen_random_uuid()::text, "userId", 'dispute_restored', $1, $2 FROM closed + WHERE $3 AND NOT EXISTS ( + SELECT FROM public.dormouse_billing_disputes other + WHERE other."userId" = closed."userId" AND other."disputeId" <> $1) + ON CONFLICT DO NOTHING`, + [disputeId, now, action === "restore"], + ), ); } diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index 083d36272..5a08736f6 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, type EmailSender } from "pgstencil"; +import { SecureRandom, SystemTime, type EmailSender, type Time } 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 { priceChanges, runNotices, usableChanges, type PriceChange } from "./notices"; import { retryWithdrawals } from "./withdrawal"; import { BILLING_PLANS, BILLING_RETURN_PATH } from "./policy-constants"; @@ -25,6 +26,8 @@ export interface BillingSetup { founding: readonly string[]; /** StripeDev's client in place of Stripe's API, for tests and the dev loop; never from bindings. */ stripe?: Stripe; + /** A test's clock in place of the system's; never from bindings. */ + time?: Time; } const PRICE = /^price_[A-Za-z0-9_]{1,200}$/; @@ -111,19 +114,32 @@ const random = new SecureRandom(); /** * Runs `action` with a `Billing` over `databaseUrl` for the account origin - * `origin`, closing its connections after. No trial; DiffPlug is the merchant - * of record on Stripe Billing, never Managed Payments, with Stripe Tax on - * every Checkout; founding checkout offers the open cohort's Price and - * refuses once founding has closed. + * `origin`, and the scheduled price changes, closing its connections after. + * No trial; DiffPlug is the merchant of record on Stripe Billing, never + * Managed Payments, with Stripe Tax on every Checkout; founding checkout + * offers the open cohort's Price and refuses once founding has closed. + * Monthly and yearly checkout offer the configured Price, and each also + * grants the Prices its usable price changes move between. */ export async function withBilling( setup: BillingSetup, databaseUrl: string, origin: string, - action: (billing: Billing) => Promise, + action: (billing: Billing, changes: readonly PriceChange[]) => Promise, ): Promise { const db = connectDatabase(databaseUrl); try { + const changes = await priceChanges(db); + const { usable } = usableChanges(setup, changes); + const prices = (plan: "monthly" | "yearly") => ({ + recognized: [ + ...new Set([ + setup[plan], + ...usable.filter((change) => change.plan === plan).flatMap((change) => [change.fromPriceId, change.toPriceId]), + ]), + ], + offer: () => setup[plan], + }); const billing = new Billing( db, setup.stripe ?? @@ -131,12 +147,12 @@ export async function withBilling( httpClient: Stripe.createFetchHttpClient(), maxNetworkRetries: 1, }), - time, + setup.time ?? time, random, { prices: { - monthly: setup.monthly, - yearly: setup.yearly, + monthly: prices("monthly"), + yearly: prices("yearly"), founding: { recognized: setup.founding, async offer(billing) { @@ -155,7 +171,7 @@ export async function withBilling( returnPath: BILLING_RETURN_PATH, }, ); - return await action(billing); + return await action(billing, changes); } finally { await db.destroy(); } @@ -168,9 +184,10 @@ 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, 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. + * any withdrawal whose acknowledgement or settlement failed, then runs the + * subscription notices and the price changes they allow. Billing closed + * does nothing. Every owner, withdrawal, and notice is tried; any failure + * fails the invocation after. */ export async function resyncBilling( setup: BillingSetup | null, @@ -187,10 +204,11 @@ export async function resyncBilling( [RESYNC_OWNERS], ); const failed: unknown[] = []; - await withBilling(setup, databaseUrl, origin, async (billing) => { + await withBilling(setup, databaseUrl, origin, async (billing, changes) => { for (const { owner } of due) await billing.reconcile(owner).catch((error: unknown) => failed.push(error)); failed.push(...(await retryWithdrawals(billing, mail))); + failed.push(...(await runNotices(billing, setup, changes, mail))); }); if (failed.length) throw new AggregateError(failed, `${failed.length} billing resyncs failed`); } diff --git a/hosted/server/dormouse-migrations/018_billing_notices.sql b/hosted/server/dormouse-migrations/018_billing_notices.sql new file mode 100644 index 000000000..c3b290aa1 --- /dev/null +++ b/hosted/server/dormouse-migrations/018_billing_notices.sql @@ -0,0 +1,59 @@ +-- Up Migration +-- A list price change the operator schedules (hosted/README.md -> "Change a +-- price"): subscriptions on `fromPriceId` move to `toPriceId` from their first +-- renewal on or after `effectiveAt` whose notice went out in time +-- (docs/specs/hosted.md -> "Billing notices"). The amounts are whole US +-- dollars per interval, checked against Stripe before any notice names them. +CREATE TABLE dormouse_price_changes ( + id text PRIMARY KEY, + plan text NOT NULL CHECK (plan IN ('monthly', 'yearly')), + "fromPriceId" text NOT NULL UNIQUE CHECK ("fromPriceId" ~ '^price_[A-Za-z0-9_]{1,200}$'), + "toPriceId" text NOT NULL CHECK ("toPriceId" ~ '^price_[A-Za-z0-9_]{1,200}$'), + "fromUsd" integer NOT NULL CHECK ("fromUsd" > 0), + "toUsd" integer NOT NULL CHECK ("toUsd" > 0), + "effectiveAt" timestamptz NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT now(), + CHECK ("fromPriceId" <> "toPriceId") +); + +-- Each subscription notice, recorded before it is sent and stamped once the +-- mail service accepted it: a renewal notice or annual reminder per +-- subscription and renewal, a price-change notice per subscription and the +-- renewal it applies at, a dispute's suspension and restoration per dispute. +-- The unique indexes make each one once, however often the cron or a +-- webhook redelivery asks. No foreign key to the account: like the +-- withdrawals, it is a billing record kept after the account is deleted. +CREATE TABLE dormouse_billing_notices ( + id text PRIMARY KEY, + "userId" text NOT NULL, + kind text NOT NULL CHECK (kind IN ('renewal', 'annual', 'price_change', 'dispute_suspended', 'dispute_restored')), + "subscriptionId" text, + -- The renewal the notice precedes. + "renewsAt" timestamptz, + "priceChangeId" text REFERENCES dormouse_price_changes (id), + "disputeId" text, + "createdAt" timestamptz NOT NULL, + -- A send in progress holds the notice until a lease past this expires. + "attemptedAt" timestamptz, + -- The billing email it went to, and when the mail service accepted it. + email text, + "sentAt" timestamptz, + -- When its subscription moved to the change's new Price. + "appliedAt" timestamptz, + CHECK ((kind IN ('renewal', 'annual', 'price_change')) = ("subscriptionId" IS NOT NULL AND "renewsAt" IS NOT NULL)), + CHECK ((kind = 'price_change') = ("priceChangeId" IS NOT NULL)), + CHECK ((kind IN ('dispute_suspended', 'dispute_restored')) = ("disputeId" IS NOT NULL)), + CHECK (("sentAt" IS NULL) = (email IS NULL)), + -- PRICE_NOTICE_MIN_DAYS in hosted/server/notices.ts: a price change applies + -- only after a notice sent at least that long before its renewal. + CHECK ("appliedAt" IS NULL OR "sentAt" <= "renewsAt" - interval '7 days') +); +CREATE UNIQUE INDEX dormouse_billing_notices_renewal + ON dormouse_billing_notices (kind, "subscriptionId", "renewsAt") WHERE "subscriptionId" IS NOT NULL; +CREATE UNIQUE INDEX dormouse_billing_notices_dispute + ON dormouse_billing_notices (kind, "disputeId") WHERE "disputeId" IS NOT NULL; +CREATE INDEX dormouse_billing_notices_unsent ON dormouse_billing_notices ("createdAt") WHERE "sentAt" IS NULL; + +-- Down Migration +DROP TABLE dormouse_billing_notices; +DROP TABLE dormouse_price_changes; diff --git a/hosted/server/notices.ts b/hosted/server/notices.ts new file mode 100644 index 000000000..fa97955cf --- /dev/null +++ b/hosted/server/notices.ts @@ -0,0 +1,409 @@ +// Rules: docs/specs/hosted.md -> "Billing notices"; docs/specs/pricing.md -> +// "Checkout and entitlement". +import type { Billing } from "@pgstencil/stripe"; +import { escape } from "@pgstencil/auth/email"; +import { CompiledQuery, type Kysely } from "kysely"; +import type { EmailMessage, EmailSender } from "pgstencil"; +import { DIFFPLUG_POSTAL_ADDRESS, SUPPORT_EMAIL } from "../../website/src/lib/hosted-withdrawal"; +import { publishedPrice, type BillingSetup, type Plan } from "./billing"; +import { BILLING_RETURN_PATH } from "./policy-constants"; +import { PLAN_NAMES, billingContact } from "./withdrawal"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * A yearly plan's renewal notice, and a monthly plan's annual reminder, are + * due this many days before the renewal they precede: inside California's + * 15 to 45 days for an annual term's renewal notice. + */ +export const RENEWAL_NOTICE_DAYS = 30; +/** + * A price change's notice is due this many days before the renewal it first + * applies at: inside California's 7 to 30 days before a fee change. + */ +export const PRICE_NOTICE_DAYS = 21; +/** + * A price change applies at a renewal only after its notice was sent at least + * this many days before it; later, the change waits for the next renewal and + * its own notice. Migration 018's CHECK repeats it. + */ +export const PRICE_NOTICE_MIN_DAYS = 7; +/** + * A price-change notice is sent only while its renewal is further off than + * this, so the send finishes before {@link PRICE_NOTICE_MIN_DAYS} with a day to + * spare and never announces a change that then cannot apply. + */ +const PRICE_SEND_BY_DAYS = PRICE_NOTICE_MIN_DAYS + 1; +/** How long the cron retries a dispute notice. */ +const DISPUTE_RETRY_DAYS = 30; +/** One send attempt holds its notice this long before another may try it. */ +const LEASE_MS = 5 * 60 * 1000; +/** How many notices one pass sends, and how many price changes it applies, at most. */ +const BATCH = 50; + +/** A scheduled list price change, as the operator recorded it in `dormouse_price_changes`. */ +export interface PriceChange { + id: string; + plan: "monthly" | "yearly"; + fromPriceId: string; + toPriceId: string; + fromUsd: number; + toUsd: number; + effectiveAt: Date; +} + +type Db = Pick, "executeQuery">; + +const run = (db: Db, text: string, values: unknown[]) => + db.executeQuery(CompiledQuery.raw(text, values)).then((result) => result.rows); + +/** Every scheduled price change, oldest first. */ +export const priceChanges = (db: Db) => + run( + db, + `SELECT id, plan, "fromPriceId", "toPriceId", "fromUsd", "toUsd", "effectiveAt" + FROM public.dormouse_price_changes ORDER BY "createdAt", id`, + [], + ); + +/** + * The changes billing may act on, and the ones it refuses: a change that + * names a founding Price, whose lock no change may touch, or a Price another + * plan's configuration or change already holds, is refused whole. + */ +export function usableChanges(setup: BillingSetup, changes: readonly PriceChange[]) { + const owner = new Map([ + [setup.monthly, "monthly"], + [setup.yearly, "yearly"], + ...setup.founding.map((price) => [price, "founding"] as [string, string]), + ]); + const usable: PriceChange[] = []; + const refused: PriceChange[] = []; + for (const change of changes) { + const prices = [change.fromPriceId, change.toPriceId]; + if (prices.some((price) => (owner.get(price) ?? change.plan) !== change.plan)) refused.push(change); + else { + for (const price of prices) owner.set(price, change.plan); + usable.push(change); + } + } + return { usable, refused }; +} + +/** What `priceId` charges per interval in whole US dollars: published, or a change's; null when neither says. */ +export function priceUsd(setup: BillingSetup, changes: readonly PriceChange[], priceId: string): number | null { + return ( + publishedPrice(setup, priceId) ?? + changes.find((change) => change.toPriceId === priceId)?.toUsd ?? + changes.find((change) => change.fromPriceId === priceId)?.fromUsd ?? + null + ); +} + +const intervalOf = (plan: Plan) => (plan === "monthly" ? "month" : "year"); + +/** Throws unless Stripe holds both of `change`'s Prices at its amounts, in USD, per its plan's interval. */ +async function verifyChange(billing: Billing, change: PriceChange) { + const interval = intervalOf(change.plan); + for (const [id, usd] of [ + [change.fromPriceId, change.fromUsd], + [change.toPriceId, change.toUsd], + ] as const) { + const price = await billing.stripe.prices.retrieve(id); + if ( + price.currency !== "usd" || + price.unit_amount !== usd * 100 || + price.recurring?.interval !== interval || + price.recurring.interval_count !== 1 || + price.livemode !== billing.config.live + ) + throw new Error(`Price change ${change.id}: Stripe's ${id} is not ${usd} USD every ${interval}`); + } +} + +type Kind = "renewal" | "annual" | "price_change" | "dispute_suspended" | "dispute_restored"; + +/** A notice to send, with what its text needs. */ +interface Pending { + id: string; + userId: string; + kind: Kind; + renewsAt: Date | null; + priceId: string | null; + priceChangeId: string | null; +} + +/** What composing a notice reads beyond its row. */ +export interface NoticeContext { + setup: BillingSetup; + changes: readonly PriceChange[]; + /** The account origin, whose billing page a notice links. */ + origin: string; +} + +const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }); + +/** The email for notice `notice`, to the billing email `to`. */ +export function noticeMessage(to: string, notice: Pending, plan: Plan | null, context: NoticeContext): EmailMessage { + const billingPage = new URL(BILLING_RETURN_PATH, context.origin).href; + const name = plan ? PLAN_NAMES[plan] : "Dormouse Hosted"; + const interval = plan ? intervalOf(plan) : null; + const usd = notice.priceId ? priceUsd(context.setup, context.changes, notice.priceId) : null; + const on = notice.renewsAt ? DAY.format(notice.renewsAt) : ""; + const cancel = `To stop it renewing, cancel before ${on} in Manage billing on your billing page: ${billingPage}. You keep Hosted until ${on}. If you cannot sign in, email ${SUPPORT_EMAIL} and we will cancel it for you.`; + const yearlyRefund = `Ask within 30 days of a yearly renewal payment and we refund it in full, including tax.`; + const help = `Questions: ${SUPPORT_EMAIL}.`; + let subject: string; + let paragraphs: string[]; + switch (notice.kind) { + case "renewal": + subject = `Your Dormouse Hosted subscription renews on ${on}`; + paragraphs = [ + `Your ${name} subscription renews automatically on ${on}${usd ? `, for another year at $${usd} plus any applicable tax, charged to your payment method on file` : ", for another year"}.`, + cancel, + yearlyRefund, + help, + ]; + break; + case "annual": + subject = "Your yearly reminder about your Dormouse Hosted subscription"; + paragraphs = [ + `This is your yearly reminder that your ${name} subscription renews automatically every month${usd ? ` at $${usd} plus any applicable tax, charged to your payment method on file` : ""}, until you cancel. Your next renewal is on ${on}.`, + `To cancel, use Manage billing on your billing page: ${billingPage}. You keep Hosted until the end of the month you paid for. If you cannot sign in, email ${SUPPORT_EMAIL} and we will cancel it for you.`, + help, + ]; + break; + case "price_change": { + const change = context.changes.find((candidate) => candidate.id === notice.priceChangeId)!; + subject = `Your Dormouse Hosted price changes on ${on}`; + paragraphs = [ + `The price of your ${name} subscription changes from $${change.fromUsd} to $${change.toUsd} a ${interval}, plus any applicable tax, starting with your renewal on ${on}. Nothing changes before then.`, + `If you do not want to pay the new price, ${cancel.charAt(0).toLowerCase()}${cancel.slice(1)}`, + ...(interval === "year" ? [yearlyRefund] : []), + help, + ]; + break; + } + case "dispute_suspended": + subject = "Dormouse Hosted is paused during a payment dispute"; + paragraphs = [ + "A payment for your Dormouse Hosted subscription is being disputed with your bank or card provider. While the dispute is open, we have paused the paid features it covers, Natural Voice and the Hosted Relay. Your subscription is not cancelled, and Dormouse keeps working, with spoken alarms in your system voice.", + "If the disputed payment is restored, or the pause was a mistake, we restore your remaining paid access and any founding price you held. If the payment is finally reversed, we may end the access it paid for and stop future renewals.", + `This does not limit your right to dispute a charge, or any refund or remedy owed to you. To reach us about the dispute, email ${SUPPORT_EMAIL}.`, + ]; + break; + case "dispute_restored": + subject = "Your Dormouse Hosted access is restored"; + paragraphs = [ + "The payment dispute on your Dormouse Hosted subscription has closed with the payment in place, and we have restored your paid access. Your subscription continues as before, including any founding price you held.", + `This does not limit any refund or remedy owed to you. ${help}`, + ]; + break; + } + paragraphs.push(DIFFPLUG_POSTAL_ADDRESS); + return { + from: `Dormouse Hosted <${SUPPORT_EMAIL}>`, + to: [to], + replyTo: SUPPORT_EMAIL, + subject, + text: paragraphs.join("\n\n"), + html: `${paragraphs.map((paragraph) => `

${escape(paragraph)}

`).join("")}`, + }; +} + +/** + * Sends each unsent notice that still applies, up to {@link BATCH}, to its + * billing email, and stamps it sent; `userId` narrows it to one account's. + * A renewal-type notice still applies while its subscription renews at that + * renewal, a price change's only while it is further off than + * {@link PRICE_SEND_BY_DAYS} and its change is among `verified`; a suspension + * while its dispute is open; a dispute notice for {@link DISPUTE_RETRY_DAYS}. + * Each is leased before it is sent, so two passes never send one twice. + * Answers the failures. + */ +export async function sendNotices( + billing: Billing, + mail: () => EmailSender, + context: NoticeContext, + { userId = null, verified = [] }: { userId?: string | null; verified?: readonly string[] } = {}, +): Promise { + const now = billing.time.now(); + const pending = await run( + billing.db, + `SELECT n.id, n."userId", n.kind, n."renewsAt", s.price_id AS "priceId", n."priceChangeId" + FROM public.dormouse_billing_notices n + LEFT JOIN pgstencil_billing.subscriptions s ON s.id = n."subscriptionId" + LEFT JOIN public.dormouse_price_changes c ON c.id = n."priceChangeId" + WHERE n."sentAt" IS NULL AND (n."attemptedAt" IS NULL OR n."attemptedAt" < $2) + AND ($3::text IS NULL OR n."userId" = $3) + AND CASE + WHEN n.kind IN ('renewal', 'annual') THEN + s.status = 'active' AND NOT s.cancel_at_period_end AND s.period_end = n."renewsAt" AND n."renewsAt" > $1::timestamptz + WHEN n.kind = 'price_change' THEN + s.status = 'active' AND NOT s.cancel_at_period_end AND s.period_end = n."renewsAt" + AND s.price_id = c."fromPriceId" AND c.id = ANY ($6::text[]) + AND n."renewsAt" > $1::timestamptz + make_interval(days => $7) + WHEN n.kind = 'dispute_suspended' THEN + n."createdAt" > $4 + AND EXISTS (SELECT FROM public.dormouse_billing_disputes d WHERE d."disputeId" = n."disputeId") + ELSE n."createdAt" > $4 + END + ORDER BY n."createdAt", n.id LIMIT $5`, + [ + now, + new Date(now.getTime() - LEASE_MS), + userId, + new Date(now.getTime() - DISPUTE_RETRY_DAYS * DAY_MS), + BATCH, + verified, + PRICE_SEND_BY_DAYS, + ], + ); + const failed: unknown[] = []; + for (const notice of pending) + try { + const [leased] = await run( + billing.db, + `UPDATE public.dormouse_billing_notices SET "attemptedAt" = $2 + WHERE id = $1 AND "sentAt" IS NULL AND ("attemptedAt" IS NULL OR "attemptedAt" < $3) RETURNING id`, + [notice.id, billing.time.now(), new Date(billing.time.now().getTime() - LEASE_MS)], + ); + if (!leased) continue; + const email = (await billingContact(billing, notice.userId))?.email; + if (!email) throw new Error(`Notice ${notice.id}: Stripe gave no billing email to send it to`); + await mail().send(noticeMessage(email, notice, billing.planOf(notice.priceId), context)); + await run(billing.db, `UPDATE public.dormouse_billing_notices SET "sentAt" = $2, email = $3 WHERE id = $1`, [ + notice.id, + billing.time.now(), + email, + ]); + } catch (error) { + failed.push(error); + } + return failed; +} + +/** + * Moves each subscription whose price-change notice was sent at least + * {@link PRICE_NOTICE_MIN_DAYS} before the renewal it named, and still + * renews then on the change's old Price, to the new Price, without + * proration, so that renewal is the first to charge it. Stripe's own copy is + * checked first. Answers the failures. + */ +async function applyPriceChanges(billing: Billing, verified: readonly string[]): Promise { + const now = billing.time.now(); + const due = await run<{ id: string; userId: string; subscriptionId: string; renewsAt: Date; fromPriceId: string; toPriceId: string }>( + billing.db, + `SELECT n.id, n."userId", n."subscriptionId", n."renewsAt", c."fromPriceId", c."toPriceId" + FROM public.dormouse_billing_notices n + JOIN pgstencil_billing.subscriptions s ON s.id = n."subscriptionId" + JOIN public.dormouse_price_changes c ON c.id = n."priceChangeId" + WHERE n.kind = 'price_change' AND n."appliedAt" IS NULL + AND n."sentAt" <= n."renewsAt" - make_interval(days => $2) + AND c.id = ANY ($3::text[]) + AND s.price_id = c."fromPriceId" AND s.period_end = n."renewsAt" + AND s.status = 'active' AND NOT s.cancel_at_period_end AND n."renewsAt" > $1::timestamptz + ORDER BY n."renewsAt", n.id LIMIT $4`, + [now, PRICE_NOTICE_MIN_DAYS, verified, BATCH], + ); + const failed: unknown[] = []; + for (const notice of due) + try { + const subscription = await billing.stripe.subscriptions.retrieve(notice.subscriptionId); + const item = subscription.items.data[0]; + if ( + subscription.items.data.length !== 1 || + item?.price.id !== notice.fromPriceId || + item.current_period_end * 1000 !== notice.renewsAt.getTime() || + subscription.status !== "active" || + subscription.cancel_at_period_end + ) { + // Stripe has moved on; the synchronized row catches up. + await billing.reconcile(notice.userId); + continue; + } + await billing.stripe.subscriptions.update( + notice.subscriptionId, + { items: [{ id: item.id, price: notice.toPriceId }], proration_behavior: "none" }, + { idempotencyKey: `dormouse-price-change-${notice.id}` }, + ); + await billing.reconcile(notice.userId); + await run(billing.db, `UPDATE public.dormouse_billing_notices SET "appliedAt" = $2 WHERE id = $1`, [ + notice.id, + billing.time.now(), + ]); + } catch (error) { + failed.push(error); + } + return failed; +} + +/** + * The cron's notice pass: records each renewal notice, annual reminder, and + * price-change notice that has come due, sends every unsent one that still + * applies, then applies each price change whose notice went out in time. + * A refused change, or one Stripe's Prices contradict, gets no notice and + * moves no one. Answers the failures. + */ +export async function runNotices( + billing: Billing, + setup: BillingSetup, + changes: readonly PriceChange[], + mail: () => EmailSender, +): Promise { + const failed: unknown[] = []; + const now = billing.time.now(); + const { usable, refused } = usableChanges(setup, changes); + for (const change of refused) + failed.push(new Error(`Price change ${change.id} names a founding Price or another plan's; it is ignored`)); + // Checked against Stripe only while someone is still on its old Price. + const held = await run<{ price: string }>( + billing.db, + `SELECT DISTINCT price_id AS price FROM pgstencil_billing.subscriptions + WHERE price_id = ANY ($1::text[]) AND status = 'active'`, + [usable.map((change) => change.fromPriceId)], + ); + const verified: string[] = []; + for (const change of usable.filter((change) => held.some(({ price }) => price === change.fromPriceId))) + await verifyChange(billing, change).then( + () => verified.push(change.id), + (error: unknown) => failed.push(error), + ); + const prices = (plan: "monthly" | "yearly") => [ + setup[plan], + ...usable.filter((change) => change.plan === plan).flatMap((change) => [change.fromPriceId, change.toPriceId]), + ]; + // A yearly term's renewal notice is its annual reminder too; a monthly + // plan's is due before each renewal that ends a year of the subscription. + await run( + billing.db, + `INSERT INTO public.dormouse_billing_notices (id, "userId", kind, "subscriptionId", "renewsAt", "createdAt") + SELECT gen_random_uuid()::text, s.owner_id, CASE WHEN s.price_id = ANY ($2::text[]) THEN 'renewal' ELSE 'annual' END, + s.id, s.period_end, $1 + FROM pgstencil_billing.subscriptions s, + LATERAL (SELECT (extract(year FROM s.period_end AT TIME ZONE 'UTC') - extract(year FROM s.started_at AT TIME ZONE 'UTC')) * 12 + + extract(month FROM s.period_end AT TIME ZONE 'UTC') - extract(month FROM s.started_at AT TIME ZONE 'UTC') AS months) term + WHERE s.status = 'active' AND NOT s.cancel_at_period_end + AND s.period_end > $1::timestamptz AND s.period_end <= $1::timestamptz + make_interval(days => $4) + AND (s.price_id = ANY ($2::text[]) OR (s.price_id = ANY ($3::text[]) AND term.months >= 12 AND mod(term.months, 12) = 0)) + ON CONFLICT DO NOTHING`, + [now, [...prices("yearly"), ...setup.founding], prices("monthly"), RENEWAL_NOTICE_DAYS], + ); + // A price change's notice, for the first renewal on or after its date that + // is still far enough off for the notice to count. + await run( + billing.db, + `INSERT INTO public.dormouse_billing_notices + (id, "userId", kind, "subscriptionId", "renewsAt", "priceChangeId", "createdAt") + SELECT gen_random_uuid()::text, s.owner_id, 'price_change', s.id, s.period_end, c.id, $1 + FROM pgstencil_billing.subscriptions s JOIN public.dormouse_price_changes c ON c."fromPriceId" = s.price_id + WHERE c.id = ANY ($2::text[]) AND s.status = 'active' AND NOT s.cancel_at_period_end + AND s.period_end >= c."effectiveAt" + AND s.period_end > $1::timestamptz + make_interval(days => $3) AND s.period_end <= $1::timestamptz + make_interval(days => $4) + ON CONFLICT DO NOTHING`, + [now, verified, PRICE_SEND_BY_DAYS, PRICE_NOTICE_DAYS], + ); + failed.push(...(await sendNotices(billing, mail, { setup, changes: usable, origin: billing.config.origin }, { verified }))); + failed.push(...(await applyPriceChanges(billing, verified))); + return failed; +} diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts index d619fc0d7..43149a8e7 100644 --- a/hosted/server/tests/billing-routes.test.ts +++ b/hosted/server/tests/billing-routes.test.ts @@ -24,9 +24,11 @@ import { CLOSE_RECENT_LOGIN, CLOSE_UNAVAILABLE, ENDED_FOR_CLOSURE, closureRoutes import { entitledSql } from "../entitlement"; import { ENDED_FOR_WITHDRAWAL, WITHDRAWAL_OPEN_DAYS } from "../withdrawal"; import { migrations } from "../migrations"; +import { DEV_FOUNDING_PRICES } from "../billing-dev"; +import { PRICE_NOTICE_DAYS, PRICE_NOTICE_MIN_DAYS, RENEWAL_NOTICE_DAYS } from "../notices"; import { TERMS_VERSION } from "../policy-constants"; import { ORIGINS } from "./bundle"; -import { stripeFake } from "./stripe-fake"; +import { CHANGED_PRICES, CHANGED_USD, stripeFake } from "./stripe-fake"; import { workerDatabases } from "./worker-roles"; // The billing routes in Node, on real Postgres and the Stripe fake: what a @@ -853,3 +855,173 @@ test("with billing closed, closing deletes an account without a subscription and 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 }]); }); + +const DAY_MS = 24 * 60 * 60 * 1000; + +test("a renewal notice and a monthly plan's annual reminder go out once, 30 days ahead, to the billing email", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const resync = () => resyncBilling(f.setup, f.database.url, origin, f.mail); + // A provider-only account: its billing email is the one Checkout collected. + const yearly = await f.account(null); + const { session, subscription } = await f.buy(yearly, "yearly"); + f.dev.customers.get(session.customer as string)!.email = "billing@example.test"; + const monthly = await f.account(); + const { subscription: month } = await f.buy(monthly, "monthly"); + const ending = await f.account(); + const { subscription: ends } = await f.buy(ending, "yearly"); + f.dev.transition(ends.id, "cancel-at-period-end"); + await f.deliver(); + const notices = () => + f.sql<{ kind: string; subscriptionId: string; email: string | null }>( + `SELECT kind, "subscriptionId", email FROM dormouse_billing_notices ORDER BY kind`, + ); + + const renewsAt = new Date(subscription.items.data[0]!.current_period_end * 1000); + f.time.advanceMilliseconds(renewsAt.getTime() - f.time.now().getTime() - (RENEWAL_NOTICE_DAYS + 1) * DAY_MS); + await resync(); + expect(await notices()).toEqual([]); + f.time.advanceMilliseconds(2 * DAY_MS); + await resync(); + await resync(); + expect(await notices()).toEqual([{ kind: "renewal", subscriptionId: subscription.id, email: "billing@example.test" }]); + const [renewal] = f.email.all(); + expect(renewal!.to).toEqual(["billing@example.test"]); + expect(renewal!.subject).toContain(String(renewsAt.getUTCFullYear())); + for (const fact of ["$100", "Manage billing", `${origin}/billing`, "support@dormouse.sh"]) expect(renewal!.text).toContain(fact); + + // The monthly plan's renewal that ends its first year gets the reminder; no other does. + await f.sql(`UPDATE pgstencil_billing.subscriptions SET started_at = period_end - interval '11 months' WHERE id = $1`, [month.id]); + await f.sql(`UPDATE pgstencil_billing.subscriptions SET period_end = $2 WHERE id = $1`, [ + month.id, + new Date(f.time.now().getTime() + 20 * DAY_MS), + ]); + await resync(); + expect(await notices()).toHaveLength(1); + await f.sql(`UPDATE pgstencil_billing.subscriptions SET started_at = period_end - interval '12 months' WHERE id = $1`, [month.id]); + await resync(); + expect((await notices()).map((notice) => [notice.kind, notice.subscriptionId])).toEqual([ + ["annual", month.id], + ["renewal", subscription.id], + ]); + expect(f.email.all().at(-1)!.text).toContain("every month at $10"); + expect(f.email.all()).toHaveLength(2); +}); + +test("a price change applies only from a renewal its notice reached at least 7 days ahead, never to a founding Price", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const resync = () => resyncBilling(f.setup, f.database.url, origin, f.mail); + const member = await f.account(); + const { subscription } = await f.buy(member, "monthly"); + const founder = await f.account(); + await f.buy(founder, "founding"); + const change = (id: string, plan: string, from: string, to: string, fromUsd: number, toUsd: number) => + f.sql( + `INSERT INTO dormouse_price_changes (id, plan, "fromPriceId", "toPriceId", "fromUsd", "toUsd", "effectiveAt") + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [id, plan, from, to, fromUsd, toUsd, f.time.now()], + ); + await change("monthly-12", "monthly", f.setup.monthly, CHANGED_PRICES.monthly, LIST_MONTHLY, CHANGED_USD.monthly); + const renewsAt = new Date(subscription.items.data[0]!.current_period_end * 1000); + const notices = () => + f.sql<{ kind: string; renewsAt: Date; sentAt: Date | null; appliedAt: Date | null }>( + `SELECT kind, "renewsAt", "sentAt", "appliedAt" FROM dormouse_billing_notices`, + ); + + // Stripe's Price disagrees with the recorded amount: no notice, no move. + f.prices.set(CHANGED_PRICES.monthly, { usd: 13, interval: "month" }); + f.time.advanceMilliseconds(renewsAt.getTime() - f.time.now().getTime() - (PRICE_NOTICE_DAYS - 1) * DAY_MS); + await expect(resync()).rejects.toThrow(AggregateError); + expect([await notices(), f.email.all(), f.moves]).toEqual([[], [], []]); + + // A notice that went out too late is never acted on. + await f.sql( + `INSERT INTO dormouse_billing_notices (id, "userId", kind, "subscriptionId", "renewsAt", "priceChangeId", "createdAt", email, "sentAt") + VALUES ('late', $1, 'price_change', $2, $3, 'monthly-12', $4, 'x@example.test', $4)`, + [member.id, subscription.id, renewsAt, new Date(renewsAt.getTime() - (PRICE_NOTICE_MIN_DAYS - 1) * DAY_MS)], + ); + f.prices.set(CHANGED_PRICES.monthly, { usd: CHANGED_USD.monthly, interval: "month" }); + await resync(); + expect([f.email.all(), f.moves]).toEqual([[], []]); + await f.sql(`DELETE FROM dormouse_billing_notices`); + + // In time: noticed 20 days ahead, then moved to the new Price without proration. + await resync(); + const [notice] = await notices(); + expect(notice).toMatchObject({ kind: "price_change", renewsAt, appliedAt: expect.any(Date) }); + expect(renewsAt.getTime() - notice!.sentAt!.getTime()).toBeGreaterThanOrEqual(PRICE_NOTICE_MIN_DAYS * DAY_MS); + const [mail] = f.email.all(); + for (const fact of ["from $10 to $12 a month", "Manage billing", `${origin}/billing`]) expect(mail!.text).toContain(fact); + expect(f.moves).toEqual([{ subscription: subscription.id, price: CHANGED_PRICES.monthly, proration: "none" }]); + expect(await member.summary()).toMatchObject({ plan: "monthly", price: CHANGED_USD.monthly, renews: true }); + await resync(); + expect([f.email.all().length, f.moves.length]).toEqual([1, 1]); + + // A change from a founding Price is refused whole, though Stripe agrees + // with it and the founder's renewal is 15 days off. + await change("founding-up", "yearly", DEV_FOUNDING_PRICES[0]!, CHANGED_PRICES.yearly, FOUNDING_LADDER[0]!, CHANGED_USD.yearly); + f.prices.set(DEV_FOUNDING_PRICES[0]!, { usd: FOUNDING_LADDER[0]!, interval: "year" }); + f.time.advanceMilliseconds(340 * DAY_MS); + const refused = (await resync().catch((error: unknown) => error)) as AggregateError; + expect(refused.errors.map((error: Error) => error.message)).toEqual([ + "Price change founding-up names a founding Price or another plan's; it is ignored", + ]); + expect(await f.sql(`SELECT kind FROM dormouse_billing_notices WHERE kind = 'price_change'`)).toHaveLength(1); + expect(f.moves).toHaveLength(1); +}); + +test("a dispute's suspension and restoration are each emailed once to the billing email, and the cron retries a failed send", 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 { session, subscription } = await f.buy(member, "yearly"); + f.paidInvoices.set("pi_du_lost", subscription.id); + const billingEmail = f.dev.customers.get(session.customer as string)!.email; + const dispute = (id: string, status: string) => { + f.disputes.set(id, { status, customer: session.customer as string, paymentIntent: `pi_${id}` }); + return f.event("charge.dispute.updated", { id, object: "dispute", status }); + }; + const subjects = () => f.email.all().map((mail) => [mail.to[0], mail.subject]); + + const opened = dispute("du_won", "needs_response"); + expect((await f.send(opened)).status).toBe(200); + expect((await f.send({ ...opened, id: "evt_redelivered" })).status).toBe(200); + expect((await f.send(dispute("du_won", "under_review"))).status).toBe(200); + expect(subjects()).toEqual([[billingEmail, "Dormouse Hosted is paused during a payment dispute"]]); + expect(f.email.all()[0]!.text).toContain("does not limit your right to dispute a charge"); + + // Restored only once no other dispute holds the account. + expect((await f.send(dispute("du_lost", "needs_response"))).status).toBe(200); + expect((await f.send(dispute("du_won", "won"))).status).toBe(200); + expect(subjects().map(([, subject]) => subject)).toEqual([ + "Dormouse Hosted is paused during a payment dispute", + "Dormouse Hosted is paused during a payment dispute", + ]); + // Lost, the subscription ends; nothing is restored. + expect((await f.send(dispute("du_lost", "lost"))).status).toBe(200); + expect(subjects()).toHaveLength(2); + expect(await f.sql(`SELECT kind FROM dormouse_billing_notices WHERE "sentAt" IS NULL`)).toEqual([]); + + // A restoration whose send fails stands recorded, and the cron sends it. + const other = await f.account(); + const { session: otherSession } = await f.buy(other, "monthly"); + f.disputes.set("du_back", { status: "needs_response", customer: otherSession.customer as string, paymentIntent: "pi_du_back" }); + expect((await f.send(f.event("charge.dispute.created", { id: "du_back", object: "dispute" }))).status).toBe(200); + f.disputes.set("du_back", { status: "won", customer: otherSession.customer as string, paymentIntent: "pi_du_back" }); + f.mailing.failures = 1; + expect((await f.send(f.event("charge.dispute.closed", { id: "du_back", object: "dispute" }))).status).toBe(200); + expect(await f.sql(`SELECT kind FROM dormouse_billing_notices WHERE "sentAt" IS NULL`)).toEqual([{ kind: "dispute_restored" }]); + f.time.advanceMilliseconds(10 * 60 * 1000); + await resyncBilling(f.setup, f.database.url, origin, f.mail); + expect(f.email.all().at(-1)!.subject).toBe("Your Dormouse Hosted access is restored"); + expect(await f.sql(`SELECT kind FROM dormouse_billing_notices WHERE "sentAt" IS NULL`)).toEqual([]); +}); diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index f0d4f366d..626114d18 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -47,6 +47,8 @@ const PINNED: Record = { "0d0fcf0a44651ca819ee279d046ef55a622fa6398cf99e3b3d69b9a045ffe74c", "017_withdrawals_and_closures.sql": "136eb6ca31280e7f3852a0a42226235b0a639537b9ce64752efca66c091580c4", + "018_billing_notices.sql": + "20fe04c88694fd1cd00a83746ec8486e9bea7b0cb35aaabfd9c5a3fc4d6555c0", }; 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 0a419d1dd..06e4d038e 100644 --- a/hosted/server/tests/stripe-fake.ts +++ b/hosted/server/tests/stripe-fake.ts @@ -3,18 +3,44 @@ import type { AddressInfo } from "node:net"; import { once } from "node:events"; import { STRIPE_API_VERSION, Stripe } from "@pgstencil/stripe"; import type { RandomSource, Time } from "pgstencil"; +import { LIST_ANNUAL, LIST_MONTHLY } from "../../../website/src/lib/hosted-pricing"; import { stripeDevBilling } from "../billing-dev"; +/** The Prices the tests' price changes move monthly and yearly subscribers to, and what they charge. */ +export const CHANGED_PRICES = { monthly: "price_dev_monthly_next", yearly: "price_dev_yearly_next" } as const; +export const CHANGED_USD = { monthly: 12, yearly: 120 } as const; + /** * 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 + * withdrawal handling, and the price changes, call and StripeDev does not + * model: a dispute's retrieval, with its charge expanded, a subscription's + * immediate cancellation, retrieval, and move to another Price, a + * customer's retrieval with its billing country, a subscription's paid + * invoices and their payments, refunds, and a Price's retrieval. Every other * request reaches StripeDev unchanged. */ export async function stripeFake(time: Time, random: RandomSource) { - const { dev, setup: devSetup } = await stripeDevBilling(time, random); + const { dev, setup: devSetup } = await stripeDevBilling(time, random, undefined, { + [CHANGED_PRICES.monthly]: "month", + [CHANGED_PRICES.yearly]: "year", + }); + /** Price id → what Stripe holds for it; tests may change an entry. */ + const prices = new Map([ + [devSetup.monthly, { usd: LIST_MONTHLY, interval: "month" }], + [devSetup.yearly, { usd: LIST_ANNUAL, interval: "year" }], + [CHANGED_PRICES.monthly, { usd: CHANGED_USD.monthly, interval: "month" }], + [CHANGED_PRICES.yearly, { usd: CHANGED_USD.yearly, interval: "year" }], + ]); + /** Each subscription a checkout completed, as StripeDev holds it: changing one changes StripeDev's. */ + const live = new Map(); + const complete = dev.completeCheckout; + dev.completeCheckout = (sessionId: string) => { + const subscription = complete(sessionId); + live.set(subscription.id, subscription); + return subscription; + }; + /** Each move to another Price billing asked for: the subscription, the Price, and the proration. */ + const moves: { subscription: string; price: string; proration: string | null }[] = []; /** Dispute id → its current status, and its charge's customer and PaymentIntent. */ const disputes = new Map(); /** PaymentIntent id → the subscription whose invoice it paid. */ @@ -102,7 +128,35 @@ export async function stripeFake(time: Time, random: RandomSource) { : [], }); } + const price = /^\/v1\/prices\/([^/]+)$/.exec(url.pathname); + if (price && req.method === "GET") { + const found = prices.get(price[1]!); + if (!found) return json(404, { error: { message: "No such price" } }); + return json(200, { + id: price[1], + object: "price", + currency: "usd", + unit_amount: found.usd * 100, + livemode: false, + recurring: { interval: found.interval, interval_count: 1 }, + }); + } const subscription = /^\/v1\/subscriptions\/([^/]+)$/.exec(url.pathname); + if (subscription && req.method === "GET") { + const found = live.get(subscription[1]!); + return found ? json(200, found) : json(404, { error: { message: "No such subscription" } }); + } + if (subscription && req.method === "POST") { + const found = live.get(subscription[1]!); + if (!found) return json(404, { error: { message: "No such subscription" } }); + const body = new URLSearchParams(raw); + const item = found.items.data[0]!; + if (body.get("items[0][id]") !== item.id) return json(400, { error: { message: "No such item" } }); + const to = body.get("items[0][price]")!; + moves.push({ subscription: found.id, price: to, proration: body.get("proration_behavior") }); + item.price = { id: to } as Stripe.Price; + return json(200, found); + } if (subscription && req.method === "DELETE") { if (failing.cancels > 0) { failing.cancels--; @@ -154,7 +208,7 @@ export async function stripeFake(time: Time, random: RandomSource) { }) as unknown as Stripe.Event; return { dev, - setup: { ...devSetup, stripe }, + setup: { ...devSetup, stripe, time }, origin, disputes, paidInvoices, @@ -162,6 +216,8 @@ export async function stripeFake(time: Time, random: RandomSource) { countries, refunds, failing, + prices, + moves, event, close: async () => { server.close(); diff --git a/hosted/server/withdrawal.ts b/hosted/server/withdrawal.ts index c88bf2b85..5f7ed7cd8 100644 --- a/hosted/server/withdrawal.ts +++ b/hosted/server/withdrawal.ts @@ -40,7 +40,8 @@ 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 = { +/** Each plan's name, as the emails billing sends name it. */ +export const PLAN_NAMES: Record = { monthly: HOSTED_MONTHLY.name, yearly: HOSTED_YEARLY.name, founding: "Founding", @@ -72,7 +73,7 @@ export interface WithdrawalRow { } /** The Stripe customer's billing email and address country, or null when Stripe cannot say. */ -async function billingContact(billing: Billing, ownerId: string) { +export async function billingContact(billing: Billing, ownerId: string) { const account = await billing.db .selectFrom("accounts") .select("customer_id") From 68fa548a459db5a017fc9f495249e814b22f0358 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 06:29:14 -0700 Subject: [PATCH 2/3] Hosted: pin the notice schedule and the send guards in the tests Co-Authored-By: Claude Opus 5.5 --- hosted/server/tests/billing-routes.test.ts | 30 +++++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts index 43149a8e7..2b8437bc9 100644 --- a/hosted/server/tests/billing-routes.test.ts +++ b/hosted/server/tests/billing-routes.test.ts @@ -25,7 +25,6 @@ import { entitledSql } from "../entitlement"; import { ENDED_FOR_WITHDRAWAL, WITHDRAWAL_OPEN_DAYS } from "../withdrawal"; import { migrations } from "../migrations"; import { DEV_FOUNDING_PRICES } from "../billing-dev"; -import { PRICE_NOTICE_DAYS, PRICE_NOTICE_MIN_DAYS, RENEWAL_NOTICE_DAYS } from "../notices"; import { TERMS_VERSION } from "../policy-constants"; import { ORIGINS } from "./bundle"; import { CHANGED_PRICES, CHANGED_USD, stripeFake } from "./stripe-fake"; @@ -857,6 +856,10 @@ test("with billing closed, closing deletes an account without a subscription and }); const DAY_MS = 24 * 60 * 60 * 1000; +/** The schedule the Terms and pricing.md promise: renewal notices 30 days ahead, price changes 21, and never under 7. */ +const RENEWAL_NOTICE_DAYS = 30; +const PRICE_NOTICE_DAYS = 21; +const PRICE_NOTICE_MIN_DAYS = 7; test("a renewal notice and a monthly plan's annual reminder go out once, 30 days ahead, to the billing email", async ({ onTestFinished, @@ -939,7 +942,17 @@ test("a price change applies only from a renewal its notice reached at least 7 d await expect(resync()).rejects.toThrow(AggregateError); expect([await notices(), f.email.all(), f.moves]).toEqual([[], [], []]); - // A notice that went out too late is never acted on. + // A notice that went out too late is never acted on, and one not yet sent + // when its renewal is under 8 days off never goes out. + const late = await f.account(); + const { subscription: lateSubscription } = await f.buy(late, "monthly"); + const soon = new Date(f.time.now().getTime() + 6 * DAY_MS); + await f.sql(`UPDATE pgstencil_billing.subscriptions SET period_end = $2 WHERE id = $1`, [lateSubscription.id, soon]); + await f.sql( + `INSERT INTO dormouse_billing_notices (id, "userId", kind, "subscriptionId", "renewsAt", "priceChangeId", "createdAt") + VALUES ('unsent', $1, 'price_change', $2, $3, 'monthly-12', $4)`, + [late.id, lateSubscription.id, soon, f.time.now()], + ); await f.sql( `INSERT INTO dormouse_billing_notices (id, "userId", kind, "subscriptionId", "renewsAt", "priceChangeId", "createdAt", email, "sentAt") VALUES ('late', $1, 'price_change', $2, $3, 'monthly-12', $4, 'x@example.test', $4)`, @@ -949,6 +962,7 @@ test("a price change applies only from a renewal its notice reached at least 7 d await resync(); expect([f.email.all(), f.moves]).toEqual([[], []]); await f.sql(`DELETE FROM dormouse_billing_notices`); + await f.sql(`UPDATE pgstencil_billing.subscriptions SET status = 'canceled' WHERE id = $1`, [lateSubscription.id]); // In time: noticed 20 days ahead, then moved to the new Price without proration. await resync(); @@ -1011,17 +1025,19 @@ test("a dispute's suspension and restoration are each emailed once to the billin expect(subjects()).toHaveLength(2); expect(await f.sql(`SELECT kind FROM dormouse_billing_notices WHERE "sentAt" IS NULL`)).toEqual([]); - // A restoration whose send fails stands recorded, and the cron sends it. + // A notice whose send fails stands recorded, and the cron sends it while + // it still holds: a suspension only while its dispute is open. const other = await f.account(); const { session: otherSession } = await f.buy(other, "monthly"); + const unsent = () => f.sql(`SELECT kind FROM dormouse_billing_notices WHERE "sentAt" IS NULL ORDER BY kind`); f.disputes.set("du_back", { status: "needs_response", customer: otherSession.customer as string, paymentIntent: "pi_du_back" }); + f.mailing.failures = 2; expect((await f.send(f.event("charge.dispute.created", { id: "du_back", object: "dispute" }))).status).toBe(200); f.disputes.set("du_back", { status: "won", customer: otherSession.customer as string, paymentIntent: "pi_du_back" }); - f.mailing.failures = 1; expect((await f.send(f.event("charge.dispute.closed", { id: "du_back", object: "dispute" }))).status).toBe(200); - expect(await f.sql(`SELECT kind FROM dormouse_billing_notices WHERE "sentAt" IS NULL`)).toEqual([{ kind: "dispute_restored" }]); + expect([f.mailing.failures, await unsent()]).toEqual([0, [{ kind: "dispute_restored" }, { kind: "dispute_suspended" }]]); f.time.advanceMilliseconds(10 * 60 * 1000); await resyncBilling(f.setup, f.database.url, origin, f.mail); - expect(f.email.all().at(-1)!.subject).toBe("Your Dormouse Hosted access is restored"); - expect(await f.sql(`SELECT kind FROM dormouse_billing_notices WHERE "sentAt" IS NULL`)).toEqual([]); + expect(subjects().slice(2)).toEqual([[f.dev.customers.get(otherSession.customer as string)!.email, "Your Dormouse Hosted access is restored"]]); + expect(await unsent()).toEqual([{ kind: "dispute_suspended" }]); }); From 5d0f6380b6152c815e6fe2ae922a90d52b7d15b4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 06:32:34 -0700 Subject: [PATCH 3/3] Terms, Privacy, and specs: subscription notices and price changes The Terms state the notice schedule, the billing-email routing and the records kept, and the dispute suspension and restoration emails. Privacy lists the notice records, Postmark sending them, and their retention. hosted.md gains "Billing notices", pricing.md promotes the built notice gates above the fold, security-hosted.md adds FAIL IF S290, and the Hosted README documents how an operator schedules a price change. Co-Authored-By: Claude Opus 5.5 --- .github/audit/hosted.md | 2 +- docs/specs/hosted.md | 21 ++++++++++++++++++++- docs/specs/pricing.md | 7 ++++--- docs/specs/pricing.rationale.md | 6 ++++-- docs/specs/security-hosted.md | 3 ++- hosted/README.md | 19 ++++++++++++++++++- scripts/security-rule-ids.json | 2 +- scripts/spec-word-budgets.json | 4 ++-- website/src/pages/Privacy.tsx | 5 +++-- website/src/pages/Terms.tsx | 4 ++-- 10 files changed, 57 insertions(+), 16 deletions(-) diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 33500b6ee..c590738fc 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. 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. +- **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. Trace a price change too: a subscriber moved to the new Price without a notice sent in time, a founding Price changed, or a notice sent to an address that is not the Stripe billing email. - **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 de16d8b3f..ac7cfd463 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -100,11 +100,30 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 - **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, and finish a bounded batch of unfinished withdrawals; a failure fails the invocation after every one 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, finish a bounded batch of unfinished withdrawals, and run the notices ("Billing notices"); 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`; `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. +## Billing notices + +Each notice is a `dormouse_billing_notices` row, recorded before it is sent and stamped once Postmark accepts it, one per kind and renewal or dispute. + +| Kind | Due | Still sent while | +|---|---|---| +| `renewal` (yearly and founding, doubling as their annual reminder) | 30 days before the renewal | the subscription is `active` and renews then | +| `annual` (monthly) | 30 days before each renewal ending a year since its start | the same | +| `price_change` | 21 days before the first renewal on or after the change's `effectiveAt` | the same, on the old Price, over 8 days ahead | +| `dispute_suspended` | a dispute opens a suspension | the dispute is open, for 30 days | +| `dispute_restored` | it closes in the account's favor, no other open | 30 days | + +- **Must send every notice to the Stripe customer's email**, never the sign-in email, so a provider-only account gets them. +- **Must record a dispute's notice in the webhook's transaction** and send it after the commit; a failed send never fails the event. +- **A price change is an operator's `dormouse_price_changes` row** (`hosted/README.md`); both its Prices grant the plan once inserted. **Never act on one naming a founding Price or another plan's**, or whose Prices Stripe does not hold at its amounts, in USD, at the plan's interval. +- **Must move a subscription to the new Price, without proration, only after its notice was sent at least 7 days before the renewal it names**, while Stripe still renews it then on the old Price; a later notice waits for the next renewal. Checks: `docs/specs/security-hosted.md` -> "Account boundary". + +Source of truth: `runNotices` and `sendNotices` in `hosted/server/notices.ts`; `recordDispute` in `hosted/server/billing-routes.ts`; `hosted/server/dormouse-migrations/018_billing_notices.sql`. + ## Account closure | Route | Credential | Success | diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index 5b2196387..77010ba21 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -67,6 +67,8 @@ Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; the `/pricing` rule - **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. +- **Must email renewal notices, annual reminders, and price-change notices with cancellation instructions to the billing email, each recorded before it is sent, inside California's windows**; a price change applies only from a renewal its notice reached in time, never to a founding Price (`docs/specs/hosted.md` -> "Billing notices"; rationale). +- **Must email the customer when a payment dispute suspends the subscription and when it is restored**, preserving refund and dispute rights. - **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`; `WithdrawalFunction` in `hosted/src/Withdrawal.tsx`; `closureTerms` in `hosted/src/Closure.tsx`; `docs/specs/hosted.md` -> "Billing" owns the rest. @@ -135,9 +137,8 @@ EEA/UK representative appointment is excluded from the internal release gate by - **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 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 confirm the built notices ("Checkout and entitlement") meet each sales territory's rules beyond California's**, and provide any additional legally required notice or consent process (rationale). +- **Must send notices other than subscription notices to the account contact email, or show them at sign-in when none exists**, recording delivery or presentation before a notice period starts. - **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**; EEA and UK withdrawal is "Checkout and entitlement". diff --git a/docs/specs/pricing.rationale.md b/docs/specs/pricing.rationale.md index a7eb65be1..6a62cdadf 100644 --- a/docs/specs/pricing.rationale.md +++ b/docs/specs/pricing.rationale.md @@ -6,10 +6,12 @@ - [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. +- California notice windows were checked against the [Attorney General's September 2025 guidance](https://oag.ca.gov/node/608083) on 2026-10-07: an annual term's renewal notice 15 to 45 days before renewal, a fee change 7 to 30 days before it takes effect, and an annual reminder that monthly plans get too. The chosen offsets sit inside them with room for cron retries: a price-change notice due at 21 days can be retried for two weeks before the 7-day floor. Subscription notices use the Stripe billing email because provider-only accounts can lack a sign-in email, and Checkout always collects one. + ## 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. +- 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. An undisplayed account notice is not treated as delivered. +- The California windows are the only ones checked so far; the other sales jurisdictions need review before launch, and a generic promise of enough time to cancel does not configure their 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 [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. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index e548a5b12..782c367e1 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. 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` | +| **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, closing an account cancels its subscription before deleting it, and a price change reaches a subscriber only from a renewal its emailed notice preceded by at least 7 days. | 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`. @@ -38,6 +38,7 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t - **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** `S290` the cron moves a subscription to a `dormouse_price_changes` row's new Price before a `dormouse_billing_notices` row records that change's notice, naming the renewal it takes effect at, as sent to the Stripe customer's email at least `PRICE_NOTICE_MIN_DAYS` before that renewal; moves one off a founding Price, with proration, or after Stripe stopped renewing it then on the old Price; sends a notice to any other address; or that table cascades from or is deleted with the account. Inspect `runNotices` and `sendNotices` in `hosted/server/notices.ts`, `withBilling` in `hosted/server/billing.ts`, and `hosted/server/dormouse-migrations/018_billing_notices.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 2760dea46..89f29f34a 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -204,10 +204,27 @@ 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. 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. +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 and subscription notices (renewal and annual reminders, price changes, dispute suspensions and restorations) through the same Postmark server as sign-in codes, from `support@dormouse.sh`, so that sender must be verified there. 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. +## Change a price + +A list price change for existing monthly or yearly subscribers is one row in `dormouse_price_changes`; the account cron sends each subscriber's notice and moves them to the new Price from their first renewal that notice reached in time (`docs/specs/hosted.md` -> "Billing notices"). Founding Prices never change, and the cron refuses a row naming one. + +1. In Stripe, create the new recurring Price on the same product: USD, the same interval, a whole-dollar amount. +2. Insert the row as the migration role. `effectiveAt` is the first moment a renewal may charge the new price. Notices go out 21 days before each subscriber's renewal, or at once for one renewing sooner, and a subscriber whose renewal is within 8 days moves at the one after: + + ```sql + INSERT INTO dormouse_price_changes (id, plan, "fromPriceId", "toPriceId", "fromUsd", "toUsd", "effectiveAt") + VALUES ('monthly-2027', 'monthly', 'price_old', 'price_new', 10, 12, '2027-03-01T00:00:00Z'); + ``` + + From then on, both Prices grant the plan. Each run checks the row against Stripe and fails, sending nothing, until both Prices match their amounts and interval; watch the account Worker's Cron Events for that. +3. On `effectiveAt`, in one reviewed release, point `STRIPE_PRICE_MONTHLY` (or `STRIPE_PRICE_YEARLY`) at the new Price and change the published price in `website/src/lib/hosted-pricing.ts`, so new checkouts sell it. Keep the row afterwards: it is what still recognizes the old Price for anyone not yet moved. + +Never edit a row once its notices have gone out; schedule a further change as a new row from the new Price. + ## Release 1. Review the exact package provenance and code revision. Run `pnpm test:hosted`, `pnpm build:hosted`, and the repository lints. Validate production migrations. diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index e20e5646e..6b19a8add 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 290, + "next": 291, "retired": [ 265 ] diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index b6145fda6..51cdd592e 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1750, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 5850, + "docs/specs/hosted.md": 6150, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4200, @@ -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": 3950, + "docs/specs/security-hosted.md": 4050, "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 4ba9323c0..909989abd 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -33,6 +33,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

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.

+

We record each subscription notice we email you, such as a renewal reminder, a price-change notice, or a payment-dispute notice: which notice, which subscription, renewal, or dispute it concerns, the billing email we sent it to, and when. To send it, we read your billing email from Stripe.

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.

}, @@ -69,7 +70,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

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.

}, { id: "providers", title: "Who processes the information", body: <> -

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.

+

Cloudflare runs the account, Relay, and voice services. Neon stores the database. Postmark delivers sign-in emails, withdrawal acknowledgements, and subscription notices 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.

@@ -78,7 +79,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

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, 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 keep subscription and billing-event records, including what you agreed to at checkout, withdrawals, the subscription notices we sent, 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.

}, { id: "choices", title: "Your choices and requests", body: <> diff --git a/website/src/pages/Terms.tsx b/website/src/pages/Terms.tsx index d970ec726..8511abd71 100644 --- a/website/src/pages/Terms.tsx +++ b/website/src/pages/Terms.tsx @@ -29,7 +29,7 @@ export const TERMS_SECTIONS: PolicySection[] = [

Every plan renews automatically at the end of each period until you cancel. There is no free trial; the refund below takes its place.

A subscription covers one individual, including that person’s work use, with no device limit and no per-device charge. It may not be shared, resold, or transferred to another person. Usage limits are described below. For shared accounts, multiple users, or organization-level features, email support@dormouse.sh.

If a renewal payment fails, the paid features stop until a payment succeeds. Dormouse keeps working in the meantime, with spoken alarms in your system voice. A partial refund or billing correction alone does not end your subscription.

-

During a payment dispute or chargeback, we may temporarily suspend the paid access covered by the disputed payment while we investigate. We will notify you and provide a way to contact us. If the suspension was mistaken or the disputed payment is restored, we will restore any remaining paid access and the founding price you held before the suspension. If the subscription payment is finally reversed, we may end the access covered by that payment and stop future renewals. This does not limit your right to dispute a charge or any refund or remedy owed to you.

+

During a payment dispute or chargeback, we may temporarily suspend the paid access covered by the disputed payment while we investigate. We will email your billing email when we suspend it, and again if we restore it, with a way to contact us. If the suspension was mistaken or the disputed payment is restored, we will restore any remaining paid access and the founding price you held before the suspension. If the subscription payment is finally reversed, we may end the access covered by that payment and stop future renewals. This does not limit your right to dispute a charge or any refund or remedy owed to you.

}, { id: "cancellation", title: "Cancellation and refunds", body: <>

You can cancel at any time from the billing portal in your account. Cancelling stops future renewals; you keep the paid features until the end of the period you already paid for. If you cannot sign in or use the portal, contact support@dormouse.sh to cancel. We may verify that you own the subscription, but do not require you to recover account access to cancel it.

@@ -85,7 +85,7 @@ export const TERMS_SECTIONS: PolicySection[] = [ }, { id: "changes", title: "Changes and contact", body: <>

We will identify the effective date of changes on this page and give notice of material changes as described in Availability and account closure, including the refund if you do not accept one. Changes do not apply retroactively to disputes. We will obtain renewed agreement where required by law.

-

We may change monthly and yearly subscription prices only from a future renewal, after notice of the new price and how to cancel. We will give the notices required where you live, including applicable renewal reminders, annual reminders, and price-change notices. A price change never changes a founder’s locked base subscription price.

+

We may change monthly and yearly subscription prices only from a future renewal, after notice of the new price and how to cancel. We email that notice about 21 days before the renewal it first applies to; if it reaches you less than 7 days before that renewal, the change waits for a later renewal and a new notice. We also email a reminder about 30 days before each yearly renewal, and a reminder once a year if you pay monthly, each with the price and how to cancel. These notices go to your billing email, and we keep a record of each one we send. We will give any further notice required where you live. A price change never changes a founder’s locked base subscription price.

For questions about these terms, contact DiffPlug LLC at support@dormouse.sh.

Our business and postal address is DiffPlug LLC, 447 Sutter St Ste 405, San Francisco, CA 94108, United States.

},