From 8fec336ad3ca58707b036f0c007e7c2e034e1093 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 18:19:58 -0700 Subject: [PATCH 1/4] Hosted: disputes outlive the account, and the cron rereads open ones A dispute on a closed account hit the dispute table's foreign key to the deleted user, so every charge.dispute.* delivery failed and a lost dispute's end never ran. Migration 019 drops it, as the other billing records have none (it also adds the closure's settledAt, which closure refunds use in a later commit). The cron now rereads a bounded batch of open disputes from Stripe, least recently checked first, so a missed close event no longer suspends an account for good: a won one restores and records its notice once, a lost one ends its subscription before its row goes. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 4 +- docs/specs/security-hosted.md | 2 +- hosted/server/billing-routes.ts | 42 +++++++++++++++ hosted/server/billing.ts | 11 ++-- hosted/server/closure.ts | 2 +- .../019_billing_records_outlive_closure.sql | 22 ++++++++ hosted/server/tests/billing-routes.test.ts | 54 +++++++++++++++++++ hosted/server/tests/migrations.test.ts | 2 + website/src/pages/Privacy.tsx | 2 +- 9 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 hosted/server/dormouse-migrations/019_billing_records_outlive_closure.sql diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 36e0877ee..d983f6749 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -100,10 +100,10 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 - **Must verify the webhook's signature over the raw body before any write** and answer 2xx only once `webhook()` has committed, any other failure 503, which Stripe retries. - **Must end at once the subscription a fully refunded charge or a lost dispute's charge paid for**, found through its invoice, while it is the owner's current one: commented `ENDED_FOR_REFUND`, after the commit and on every delivery until ended. A partial refund ends nothing, nor does a charge for an earlier subscription or none, which is logged for the operator. - **Must offer the online withdrawal only from the held subscription while `withdrawalOffer` holds**, once per subscription; else withdraw answers 409 `WITHDRAWAL_CLOSED`. A recorded withdrawal (`dormouse_withdrawals`) is then acknowledged by email to the Stripe customer's address and settled: its subscription ended at once (`ENDED_FOR_WITHDRAWAL`), every paid invoice payment refunded in full. Each step is stamped once done and safe to repeat; the cron retries a failed one for 30 days. Checks: `docs/specs/security-hosted.md` -> "Account boundary". -- **Must suspend, never end, on an open dispute**, by its current status read from Stripe, not the event (`dormouse_billing_disputes`); closing in the account's favor restores it. +- **Must suspend, never end, on an open dispute**, by its current status read from Stripe, not the event (`dormouse_billing_disputes`, which outlives the account); 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 answer `/api/hosted/*` without resolving a login**, each answer reused for `COHORT_CACHE_MS` per isolate; `total` counts founding purchases net of refunds. The site's Worker forwards it from `dormouse.sh` to this origin, so the 421 gate needs no exception. What it answers and fetches: `docs/specs/security-hosted.md` -> "Account boundary". -- **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 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, reread from Stripe a bounded batch of open disputes, least recently checked first, acting as their webhook would, 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`; `cohortRoutes` in `hosted/server/cohort-routes.ts`; `forwardHosted` in `website/public/_worker.js`. Pinned by `hosted/server/tests/billing.test.ts` and `hosted/server/tests/billing-routes.test.ts`, on StripeDev. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 02f6f67e9..00302bcbb 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -38,7 +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` `/api/hosted/*` resolves a login or reads a cookie; names an account id, or a founder without a `dormouse_founders` row, one `subscribedSql` does not hold for on the founding Prices, or past the first `MAX_SHOWN_FOUNDERS`; serves a picture for anyone not in that answer, from anything but an `https:` URL on `AVATAR_HOSTS` fetched without following a redirect or a raster `data:` URL, past `AVATAR_BYTES`, or typed by anything but its bytes as PNG, JPEG, GIF, or WebP; or the site's Worker forwards a request under `/api/hosted/` but a GET or HEAD, to any origin but the account's, with the reader's cookie, credentials, address, or query, or passes back a header but the type, length, and caching. Inspect `cohortRoutes` in `hosted/server/cohort-routes.ts` and `forwardHosted` in `website/public/_worker.js`. - **FAIL IF** `S289` 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** `S290` 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` 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, open disputes, or its closure record. Inspect `closureRoutes` in `hosted/server/closure.ts`, `hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql`, and `hosted/server/dormouse-migrations/019_billing_records_outlive_closure.sql`. - **FAIL IF** `S291` 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/server/billing-routes.ts b/hosted/server/billing-routes.ts index 0a5344b54..ee833e2b5 100644 --- a/hosted/server/billing-routes.ts +++ b/hosted/server/billing-routes.ts @@ -419,6 +419,48 @@ async function recordDispute( ); } +/** How many open disputes one cron run reads from Stripe at most. */ +const DISPUTE_RESYNC_BATCH = 20; + +/** + * The cron's dispute pass, so a missed close event never suspends an account + * for good: reads the current status of up to {@link DISPUTE_RESYNC_BATCH} + * open disputes from Stripe, least recently checked first, and acts on each + * as its webhook would. A lost one ends its subscription before its row + * goes, so a failed end is retried; one closed either way records its + * restoration notice as the webhook does, which the notice pass then sends. + * Answers the failures. + */ +export async function resyncDisputes(billing: Billing): Promise { + const { rows } = await billing.db.executeQuery<{ disputeId: string; userId: string }>( + CompiledQuery.raw( + `SELECT "disputeId", "userId" FROM public.dormouse_billing_disputes + ORDER BY "checkedAt" NULLS FIRST, "openedAt" LIMIT $1`, + [DISPUTE_RESYNC_BATCH], + ), + ); + const failed: unknown[] = []; + for (const { disputeId, userId } of rows) + try { + const current = await billing.stripe.disputes.retrieve(disputeId, { expand: ["charge"] }); + const action = disputeAction(current.status); + if (action === "end") await endForRefund(billing, current.charge as Stripe.Charge); + await billing.db.transaction().execute(async (trx) => { + const now = billing.time.now(); + await trx.executeQuery( + CompiledQuery.raw(`UPDATE public.dormouse_billing_disputes SET "checkedAt" = $2 WHERE "disputeId" = $1`, [ + disputeId, + now, + ]), + ); + if (action !== "suspend") await recordDispute(trx, disputeId, userId, action, now); + }); + } catch (error) { + failed.push(error); + } + return failed; +} + /** * Ends at once, without proration or a final invoice, the subscription that * `charge` paid for, now fully refunded or finally reversed, while it is its diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index 81523b3d2..94cde1a86 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -4,6 +4,7 @@ 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 { resyncDisputes } from "./billing-routes"; import { grantsAccessSql } from "./entitlement"; import { priceChanges, runNotices, usableChanges, type PriceChange } from "./notices"; import { retryWithdrawals } from "./withdrawal"; @@ -193,10 +194,11 @@ 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, 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. + * any withdrawal whose acknowledgement or settlement failed, then rereads a + * batch of open disputes from Stripe, then runs the subscription notices + * and the price changes they allow. Billing closed does nothing. Every + * owner, withdrawal, dispute, and notice is tried; any failure fails the + * invocation after. */ export async function resyncBilling( setup: BillingSetup | null, @@ -217,6 +219,7 @@ export async function resyncBilling( for (const { owner } of due) await billing.reconcile(owner).catch((error: unknown) => failed.push(error)); failed.push(...(await retryWithdrawals(billing, mail))); + failed.push(...(await resyncDisputes(billing))); 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/closure.ts b/hosted/server/closure.ts index ac8ad22a2..4fe758851 100644 --- a/hosted/server/closure.ts +++ b/hosted/server/closure.ts @@ -86,7 +86,7 @@ export function closureRoutes(app: Hono, host: (c: Context) => ClosureHost) canceled = []; } // The account's rows cascade from its user; billing records, consents, - // withdrawals, and this closure have no foreign key and stay. + // withdrawals, disputes, and this closure have no foreign key and stay. const [closed] = await accountQuery<{ burrows: string[] | null }>( deployment, `WITH burrows AS (SELECT array_agg("burrowId") AS ids FROM dormouse_relay_burrows WHERE "userId" = $1), diff --git a/hosted/server/dormouse-migrations/019_billing_records_outlive_closure.sql b/hosted/server/dormouse-migrations/019_billing_records_outlive_closure.sql new file mode 100644 index 000000000..dd4db2073 --- /dev/null +++ b/hosted/server/dormouse-migrations/019_billing_records_outlive_closure.sql @@ -0,0 +1,22 @@ +-- Up Migration +-- A payment dispute's suspension is a billing record like the others +-- (docs/specs/hosted.md -> "Billing"): no foreign key, so a dispute on a +-- closed account still records, and a lost one still ends its subscription. +ALTER TABLE dormouse_billing_disputes DROP CONSTRAINT "dormouse_billing_disputes_userId_fkey"; +-- When the cron last read the dispute's status from Stripe; it reads the +-- least recently checked first, so a missed close event cannot suspend an +-- account for good. +ALTER TABLE dormouse_billing_disputes ADD COLUMN "checkedAt" timestamptz; + +-- When every payment the closure refunds had been refunded in full, or the +-- closure was found to refund none; the cron retries until it is set. A +-- closure recorded before closures refunded refunds nothing. +ALTER TABLE dormouse_account_closures ADD COLUMN "settledAt" timestamptz; +UPDATE dormouse_account_closures SET "settledAt" = "closedAt"; + +-- Down Migration +ALTER TABLE dormouse_account_closures DROP COLUMN "settledAt"; +ALTER TABLE dormouse_billing_disputes DROP COLUMN "checkedAt"; +DELETE FROM dormouse_billing_disputes d WHERE NOT EXISTS (SELECT FROM "user" u WHERE u.id = d."userId"); +ALTER TABLE dormouse_billing_disputes + ADD CONSTRAINT "dormouse_billing_disputes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" (id) ON DELETE CASCADE; diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts index 1f08a43aa..39d2cba5a 100644 --- a/hosted/server/tests/billing-routes.test.ts +++ b/hosted/server/tests/billing-routes.test.ts @@ -606,6 +606,60 @@ test("an open dispute suspends without ending, closing it in the account's favor expect(await member.summary()).toMatchObject({ plan: null, entitled: false, suspended: false }); }); +test("the cron rereads open disputes from Stripe, so a missed close event restores once or ends", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const resync = () => resyncBilling(f.setup, f.database.url, origin, f.mail); + const won = await f.account(); + const { session: wonSession } = await f.buy(won, "yearly"); + const lost = await f.account(); + const { session: lostSession, subscription: lostSubscription } = await f.buy(lost, "yearly"); + f.paidInvoices.set("pi_du_lost", lostSubscription.id); + const open = (id: string, customer: string) => { + f.disputes.set(id, { status: "needs_response", customer, paymentIntent: `pi_${id}` }); + return f.send(f.event("charge.dispute.created", { id, object: "dispute" })); + }; + expect((await open("du_won", wonSession.customer as string)).status).toBe(200); + expect((await open("du_lost", lostSession.customer as string)).status).toBe(200); + expect([await f.entitledOn(won.id), await f.entitledOn(lost.id)]).toEqual([false, false]); + const restored = () => f.email.all().filter((mail) => mail.subject === "Your Dormouse Hosted access is restored"); + + // Still open: the cron changes nothing. + await resync(); + expect([await f.entitledOn(won.id), restored(), f.cancels]).toEqual([false, [], []]); + + // Both close in Stripe, and neither close event arrives. + f.disputes.get("du_won")!.status = "won"; + f.disputes.get("du_lost")!.status = "lost"; + await resync(); + expect(await f.entitledOn(won.id)).toBe(true); + expect(restored().map((mail) => mail.to[0])).toEqual([f.dev.customers.get(wonSession.customer as string)!.email]); + expect(f.cancels).toEqual([{ subscription: lostSubscription.id, comment: ENDED_FOR_REFUND }]); + expect(await f.sql(`SELECT "disputeId" FROM dormouse_billing_disputes`)).toEqual([]); + await resync(); + expect([restored().length, f.cancels.length]).toEqual([1, 1]); +}); + +test("a dispute on a closed account still records, and a lost one still runs its end", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.account(); + const { session, subscription } = await f.buy(member, "yearly"); + f.paidInvoices.set("pi_du_closed", subscription.id); + expect((await member.request("/api/account/close", { method: "POST", body: { close: true } })).status).toBe(204); + await f.deliver(); + const dispute = (status: string) => { + f.disputes.set("du_closed", { status, customer: session.customer as string, paymentIntent: "pi_du_closed" }); + return f.send(f.event("charge.dispute.updated", { id: "du_closed", object: "dispute", status })); + }; + expect((await dispute("needs_response")).status).toBe(200); + expect(await f.sql(`SELECT "userId" FROM dormouse_billing_disputes`)).toEqual([{ userId: member.id }]); + expect((await dispute("lost")).status).toBe(200); + expect(await f.sql(`SELECT "userId" FROM dormouse_billing_disputes`)).toEqual([]); + // Closing already ended the subscription; the lost dispute ends nothing more. + expect(f.cancels.map((cancel) => cancel.comment)).toEqual([ENDED_FOR_CLOSURE]); +}); + test("a purchase ending otherwise counts as canceled, and one ending within the refund window as refunded", async ({ onTestFinished, }) => { diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index 626114d18..d728f6cc8 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -49,6 +49,8 @@ const PINNED: Record = { "136eb6ca31280e7f3852a0a42226235b0a639537b9ce64752efca66c091580c4", "018_billing_notices.sql": "20fe04c88694fd1cd00a83746ec8486e9bea7b0cb35aaabfd9c5a3fc4d6555c0", + "019_billing_records_outlive_closure.sql": + "0f8569957ef76ba35ce81460d68fdae915674dced385058b490cd099d03b8242", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index e48624981..b4cd7402e 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -79,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, 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 keep subscription and billing-event records, including what you agreed to at checkout, withdrawals, payment disputes still open, 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: <> From bf7f379accf955514bb9e23adda0f44165960db1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 18:21:24 -0700 Subject: [PATCH 2/4] Hosted: a refund in the open founding cohort returns its seat openCohort closed a cohort on its gross count but reported seats from its net count, so a refund in the open cohort never freed a seat: 99 bought and 4 refunded showed 5 left and closed after 1. The open cohort is now the last step anyone bought at while its net count is short of full, else the next; an earlier step stays closed once a purchase landed after it, so a refund there never reopens the cheaper price. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 2 +- hosted/server/billing.ts | 15 ++++++---- hosted/server/tests/billing.test.ts | 43 +++++++++++++++++++---------- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index d983f6749..4cdc3b98e 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -101,7 +101,7 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 - **Must end at once the subscription a fully refunded charge or a lost dispute's charge paid for**, found through its invoice, while it is the owner's current one: commented `ENDED_FOR_REFUND`, after the commit and on every delivery until ended. A partial refund ends nothing, nor does a charge for an earlier subscription or none, which is logged for the operator. - **Must offer the online withdrawal only from the held subscription while `withdrawalOffer` holds**, once per subscription; else withdraw answers 409 `WITHDRAWAL_CLOSED`. A recorded withdrawal (`dormouse_withdrawals`) is then acknowledged by email to the Stripe customer's address and settled: its subscription ended at once (`ENDED_FOR_WITHDRAWAL`), every paid invoice payment refunded in full. Each step is stamped once done and safe to repeat; the cron retries a failed one for 30 days. Checks: `docs/specs/security-hosted.md` -> "Account boundary". - **Must suspend, never end, on an open dispute**, by its current status read from Stripe, not the event (`dormouse_billing_disputes`, which outlives the account); 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 offer founding at the last ladder step with a completed purchase while its purchases, less those ended within `REFUND_DAYS`, are short of `FOUNDING_COHORT_SIZE`, else at the step after it**, its seats left counted the same way; an earlier step never reopens. Past the last step, founding checkout answers 409. - **Must answer `/api/hosted/*` without resolving a login**, each answer reused for `COHORT_CACHE_MS` per isolate; `total` counts founding purchases net of refunds. The site's Worker forwards it from `dormouse.sh` to this origin, so the 421 gate needs no exception. What it answers and fetches: `docs/specs/security-hosted.md` -> "Account boundary". - **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, reread from Stripe a bounded batch of open disputes, least recently checked first, acting as their webhook would, 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. diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index 94cde1a86..dba5a34dc 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -86,14 +86,17 @@ export interface OpenCohort { /** * The open cohort, from purchases per ladder step: `gross` counts every - * completed purchase, refunded or not, and `net` leaves refunds out. A cohort - * closes when its gross count fills, so a refund after it closed never - * reopens the cheaper step; seats left in the open one come from its net - * count, so a refund there returns the seat. + * completed purchase, refunded or not, and `net` leaves refunds out. The open + * cohort is the last step anyone bought at while its net count is short of + * full, else the step after it, so a refund there returns the seat; an + * earlier step closed for good once a purchase landed after it, so a refund + * there never reopens the cheaper price. */ export function openCohort(gross: readonly number[], net: readonly number[]): OpenCohort | null { - const cohort = gross.findIndex((sold) => sold < FOUNDING_COHORT_SIZE); - if (cohort === -1) return null; + let bought = gross.length - 1; + while (bought >= 0 && !gross[bought]) bought--; + const cohort = bought !== -1 && (net[bought] ?? 0) >= FOUNDING_COHORT_SIZE ? bought + 1 : Math.max(bought, 0); + if (cohort >= FOUNDING_LADDER.length) return null; return { cohort, seatsLeft: Math.max(0, FOUNDING_COHORT_SIZE - (net[cohort] ?? 0)) }; } diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts index 70ebd081e..b2e1eae7b 100644 --- a/hosted/server/tests/billing.test.ts +++ b/hosted/server/tests/billing.test.ts @@ -55,21 +55,28 @@ test("billing is closed without every Stripe binding, and refuses a ladder the p expect(() => billingSetup({ ...env, STRIPE_PRICE_MONTHLY: "price_m'" })).toThrow(/distinct/); }); -test("a cohort closes on its gross purchases, so a refund after it closed never reopens it", () => { +test("the open cohort is the last one bought at until its net count fills: a refund there returns a seat, one before never reopens", () => { const full = FOUNDING_COHORT_SIZE; const counts = (...head: number[]) => [...head, ...zeros().slice(head.length)]; expect(openCohort(zeros(), zeros())).toEqual({ cohort: 0, seatsLeft: full }); expect(openCohort(counts(3), counts(3))).toEqual({ cohort: 0, seatsLeft: full - 3 }); - // A refund in the open cohort returns its seat. + // A refund in the open cohort returns its seat, and it closes on its net count. expect(openCohort(counts(3), counts(2))).toEqual({ cohort: 0, seatsLeft: full - 2 }); + expect(openCohort(counts(full - 1), counts(full - 5))).toEqual({ cohort: 0, seatsLeft: 5 }); + expect(openCohort(counts(full + 2), counts(full - 1))).toEqual({ cohort: 0, seatsLeft: 1 }); expect(openCohort(counts(full), counts(full))).toEqual({ cohort: 1, seatsLeft: full }); - // Refunded right after it closed: the price stays up. - expect(openCohort(counts(full), counts(full - 1))).toEqual({ cohort: 1, seatsLeft: full }); + // Refunded once a purchase landed at the next step: the price stays up. + expect(openCohort(counts(full, 1), counts(full - 1, 1))).toEqual({ cohort: 1, seatsLeft: full - 1 }); // Concurrent checkouts oversold it; the next still opens at a full count. expect(openCohort(counts(full + 4), counts(full + 4))).toEqual({ cohort: 1, seatsLeft: full }); - expect(openCohort(counts(full, 2), counts(full - 1, 1))).toEqual({ cohort: 1, seatsLeft: full - 1 }); const all = Array(FOUNDING_LADDER.length).fill(full); - expect(openCohort(all, all.map((n) => n - 1))).toBeNull(); + expect(openCohort(all, all)).toBeNull(); + // A refund in the last cohort reopens its seat; one in an earlier cohort does not. + expect(openCohort(all, all.map((n, step) => (step === all.length - 1 ? n - 1 : n)))).toEqual({ + cohort: all.length - 1, + seatsLeft: 1, + }); + expect(openCohort(all, all.map((n, step) => (step === 0 ? n - 1 : n)))).toBeNull(); }); async function fixture() { @@ -199,7 +206,7 @@ test("a subscription entitles its account on every Worker's role while it grants expect(await f.entitledOn(await f.user(ADMIN_EMAIL, false))).toEqual(everywhere(false)); }); -test("founding checkout offers the open cohort's Price, holds it after a refund, and refuses once the ladder is full", async ({ +test("founding checkout offers the open cohort's Price, returns a refunded seat in it, holds it after one before, and refuses once full", async ({ onTestFinished, }) => { const f = await fixture(); @@ -209,7 +216,7 @@ test("founding checkout offers the open cohort's Price, holds it after a refund, f.sql( `WITH owners AS ( INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at) - SELECT 'filler-' || $2::int || '-' || n, 'filler@example.test', now() FROM generate_series(1, $1::int) n + SELECT 'filler-' || $2::int || '-' || gen_random_uuid(), 'filler@example.test', now() FROM generate_series(1, $1::int) RETURNING owner_id) INSERT INTO pgstencil_billing.subscriptions (id, owner_id, price_id, status, started_at, period_end, cancel_at_period_end, updated_at) @@ -223,14 +230,22 @@ test("founding checkout offers the open cohort's Price, holds it after a refund, const ada = await f.user(); await f.buy(ada, "founding"); expect(await f.entitledOn(ada)).toEqual(everywhere(true)); + const refund = (owner: string) => + f.sql( + `UPDATE pgstencil_billing.subscriptions SET status = 'canceled', ended_at = started_at + interval '1 day' + WHERE owner_id = $1`, + [owner], + ); + // Ada is refunded in the open cohort: her seat returns, so it takes one more to close it. + await refund(ada); await fill(0, FOUNDING_COHORT_SIZE - 1); + expect(await offered()).toBe(LADDER[0]); + const bea = await f.user(); + await f.buy(bea, "founding"); expect(await offered()).toBe(LADDER[1]); - // Ada is refunded right after the cohort closed: her seat returns, and the price stays up. - await f.sql( - `UPDATE pgstencil_billing.subscriptions SET status = 'canceled', ended_at = started_at + interval '1 day' - WHERE owner_id = $1`, - [ada], - ); + // Bea is refunded once a purchase landed at the next step: the price stays up. + await fill(1, 1); + await refund(bea); expect(await offered()).toBe(LADDER[1]); for (let step = 1; step < LADDER.length; step++) await fill(step, FOUNDING_COHORT_SIZE); From 29fd104d482a4f13492b01c88f1c4c2d83be855e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 18:44:15 -0700 Subject: [PATCH 3/4] Hosted: closing inside the 30-day guarantee refunds, and leaving the founders row needs no billing Owner decision: closing an account within 30 days of a subscription's first payment, or of a yearly renewal payment, refunds that payment in full, as the guarantee would. A first payment is judged by purchaseStanding's refund window, so the seat a closure frees is now one it refunded; a monthly renewal is never refunded. The closure records a settledAt once its refunds are done; the refunds reuse the withdrawal's idempotent refund (now refundPaidInvoices), and the cron retries a failed one for 30 days. The closure dialog, the Terms' closure paragraph, Privacy, pricing.md, hosted.md, and S290 say so. Billing's own refusal during closure, a checkout still being created, now answers its status and message instead of a generic 500/503. Leaving the founders row ran inside billed(), so it failed while billing was closed or misconfigured though the spec says leaving always works. It now deletes the row without billing and answers 204; the page reads its plan again. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 9 +- docs/specs/pricing.md | 2 +- docs/specs/security-hosted.md | 4 +- hosted/server/billing-routes.ts | 28 ++-- hosted/server/billing.ts | 12 +- hosted/server/closure.ts | 141 +++++++++++++++++---- hosted/server/tests/billing-routes.test.ts | 95 +++++++++++++- hosted/server/tests/stripe-fake.ts | 41 ++++-- hosted/server/withdrawal.ts | 51 +++++--- hosted/src/App.test.tsx | 16 ++- hosted/src/App.tsx | 17 ++- hosted/src/Closure.tsx | 16 +-- hosted/src/api.ts | 11 +- scripts/spec-word-budgets.json | 2 +- website/src/pages/Privacy.tsx | 2 +- website/src/pages/Terms.tsx | 2 +- 16 files changed, 341 insertions(+), 108 deletions(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 4cdc3b98e..b1d302963 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -84,7 +84,7 @@ The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` -> | `POST /api/billing/checkout` | login cookie, exact `Origin`, JSON `{ plan, ref?, terms, autoRenew, price }` | 200 `{ url }` of Stripe Checkout | | `POST /api/billing/confirm` | login cookie, exact `Origin`, JSON `{ checkout }` | 200, the `GET` body | | `POST /api/billing/portal` | login cookie, exact `Origin` | 200 `{ url }` of the customer portal | -| `PUT /api/billing/founder` | login cookie, exact `Origin`, JSON `{ shown, name? }` | 200, the `GET` body | +| `PUT /api/billing/founder` | login cookie, exact `Origin`, JSON `{ shown, name? }` | 200, the `GET` body; leaving, 204 | | `POST /api/billing/withdraw` | login cookie, exact `Origin`, JSON `{ subscription }` | 200, the `GET` body | | `POST /api/billing/webhook` | `Stripe-Signature` over the raw body | 200 once `webhook()` committed | | `GET /api/hosted/cohorts` | none | 200 `{ cohort?, seatsLeft?, founders: { total, shown } }` | @@ -92,7 +92,7 @@ The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` -> Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 without a login, and cap a body at 4 KiB. -- **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, every route past the login gate answers 503 `CHECKOUT_CLOSED` and the resync reads nothing. **Must refuse a founding list whose length differs from `FOUNDING_LADDER`**, or a malformed or repeated Price. Live mode follows the key's prefix. +- **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, every route past the login gate but leaving the founders row answers 503 `CHECKOUT_CLOSED` and the resync reads nothing. **Must refuse a founding list whose length differs from `FOUNDING_LADDER`**, or a malformed or repeated Price. Live mode follows the key's prefix. - **Never offer a trial or Managed Payments; must enable Stripe Tax on every Checkout.** - **Never take a Price, customer, owner, quantity, or return URL from a request**: checkout takes a plan name and a `ref`, normalized as `checkoutRef` does; the owner is the login, its email only once verified (else Checkout asks), and Stripe returns to `/billing`. A checkout open for another plan is expired first; a held subscription refuses another with 409. - **Must start a checkout only after agreement to `TERMS_VERSION` and to automatic renewal at the `price` the page showed, recorded in `dormouse_checkout_consents` before its URL is answered**; another version answers 409 `TERMS_CHANGED`, and a checkout whose Price `publishedPrice` does not read as `price` is expired and answers 409 `PRICE_CHANGED`. `GET` shows the latest while a subscription is held. @@ -101,9 +101,9 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 - **Must end at once the subscription a fully refunded charge or a lost dispute's charge paid for**, found through its invoice, while it is the owner's current one: commented `ENDED_FOR_REFUND`, after the commit and on every delivery until ended. A partial refund ends nothing, nor does a charge for an earlier subscription or none, which is logged for the operator. - **Must offer the online withdrawal only from the held subscription while `withdrawalOffer` holds**, once per subscription; else withdraw answers 409 `WITHDRAWAL_CLOSED`. A recorded withdrawal (`dormouse_withdrawals`) is then acknowledged by email to the Stripe customer's address and settled: its subscription ended at once (`ENDED_FOR_WITHDRAWAL`), every paid invoice payment refunded in full. Each step is stamped once done and safe to repeat; the cron retries a failed one for 30 days. Checks: `docs/specs/security-hosted.md` -> "Account boundary". - **Must suspend, never end, on an open dispute**, by its current status read from Stripe, not the event (`dormouse_billing_disputes`, which outlives the account); closing in the account's favor restores it. -- **Must offer founding at the last ladder step with a completed purchase while its purchases, less those ended within `REFUND_DAYS`, are short of `FOUNDING_COHORT_SIZE`, else at the step after it**, its seats left counted the same way; an earlier step never reopens. Past the last step, founding checkout answers 409. +- **Must offer founding at the last ladder step bought at while its purchases net of those ended within `REFUND_DAYS` are short of `FOUNDING_COHORT_SIZE`, else the next**, counting seats left the same way; an earlier step never reopens. Past the last step, founding checkout answers 409. - **Must answer `/api/hosted/*` without resolving a login**, each answer reused for `COHORT_CACHE_MS` per isolate; `total` counts founding purchases net of refunds. The site's Worker forwards it from `dormouse.sh` to this origin, so the 421 gate needs no exception. What it answers and fetches: `docs/specs/security-hosted.md` -> "Account boundary". -- **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, reread from Stripe a bounded batch of open disputes, least recently checked first, acting as their webhook would, and run the notices ("Billing notices"); 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 closure refunds, reread the least recently checked open disputes from Stripe as their webhook would, 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`; `cohortRoutes` in `hosted/server/cohort-routes.ts`; `forwardHosted` in `website/public/_worker.js`. Pinned by `hosted/server/tests/billing.test.ts` and `hosted/server/tests/billing-routes.test.ts`, on StripeDev. @@ -137,6 +137,7 @@ Errors are JSON `{ message }`; it needs no entitlement and works with billing cl - **Must refuse a login older than `LOGIN_FRESH_AGE_MS`** with 403 `CLOSE_RECENT_LOGIN`, and, billing closed, an account with a current subscription row with 503 `CLOSE_UNAVAILABLE`. - **Must expire an open checkout, then cancel every current subscription in Stripe (`ENDED_FOR_CLOSURE`), before one statement records the closure (`dormouse_account_closures`) and deletes the `"user"` row**; rows without a foreign key stay. Its Burrows' sockets then close as a removal's do ("Relay sockets"). Checks: `docs/specs/security-hosted.md` -> "Account boundary". +- **Must then refund what closing owes** (`docs/specs/pricing.md` -> "Checkout and entitlement"): a first payment while `purchaseStanding` counts the end a refund, a yearly or founding renewal paid within `REFUND_DAYS` of it. The closure is stamped settled once done; the cron retries for 30 days. Source of truth: `closureRoutes` in `hosted/server/closure.ts`; `CloseAccount` in `hosted/src/Closure.tsx`. diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index fb1670b59..b62d17413 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -69,7 +69,7 @@ Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; `fetchCohort` in `w - **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. +- **Closing an account cancels its subscription at once** (`docs/specs/hosted.md` -> "Account closure"). **Closing within 30 days of a subscription's first payment, or of a yearly renewal payment, refunds that payment in full.** **Must say before closing** that access ends now and renewal stops, which payment it refunds, that withdrawal stays 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. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 00302bcbb..83ea2542f 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, 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. The public founders row shows only founders who chose to be shown, and their pictures come from Dormouse's servers, never the sign-in provider. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/billing-routes.test.ts`, `website/src/lib/site-worker.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 refunds a payment inside the 30-day guarantee, and a price change reaches a subscriber only from a renewal its emailed notice preceded by at least 7 days. The public founders row shows only founders who chose to be shown, and their pictures come from Dormouse's servers, never the sign-in provider. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/billing-routes.test.ts`, `website/src/lib/site-worker.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,7 +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` `/api/hosted/*` resolves a login or reads a cookie; names an account id, or a founder without a `dormouse_founders` row, one `subscribedSql` does not hold for on the founding Prices, or past the first `MAX_SHOWN_FOUNDERS`; serves a picture for anyone not in that answer, from anything but an `https:` URL on `AVATAR_HOSTS` fetched without following a redirect or a raster `data:` URL, past `AVATAR_BYTES`, or typed by anything but its bytes as PNG, JPEG, GIF, or WebP; or the site's Worker forwards a request under `/api/hosted/` but a GET or HEAD, to any origin but the account's, with the reader's cookie, credentials, address, or query, or passes back a header but the type, length, and caching. Inspect `cohortRoutes` in `hosted/server/cohort-routes.ts` and `forwardHosted` in `website/public/_worker.js`. - **FAIL IF** `S289` 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** `S290` 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, open disputes, or its closure record. Inspect `closureRoutes` in `hosted/server/closure.ts`, `hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql`, and `hosted/server/dormouse-migrations/019_billing_records_outlive_closure.sql`. +- **FAIL IF** `S290` 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`; stamps a closure settled before every payment `closureRefunds` accepts was refunded in full, or leaves a failed refund to no cron; or deletes or cascades the billing records, checkout consents, withdrawals, open disputes, or its closure record. Inspect `hosted/server/closure.ts`, `resyncBilling` in `hosted/server/billing.ts`, `hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql`, and `hosted/server/dormouse-migrations/019_billing_records_outlive_closure.sql`. - **FAIL IF** `S291` 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/server/billing-routes.ts b/hosted/server/billing-routes.ts index ee833e2b5..ab1616bcb 100644 --- a/hosted/server/billing-routes.ts +++ b/hosted/server/billing-routes.ts @@ -252,7 +252,8 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) }); // The founders row: shown under `name` only while a founding subscription - // grants access; leaving it is always allowed. + // grants access. Leaving it needs nothing from billing, so it works while + // billing is closed or failing, and answers 204. app.put("/api/billing/founder", limit(JSON_BODY_BYTES), gate, async (c) => { const { userId } = c.get("login"); const body = await readJson<{ shown?: unknown; name?: unknown }>(c); @@ -260,19 +261,20 @@ 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); + if (!shown) { + await accountQuery(host(c), `DELETE FROM dormouse_founders WHERE "userId" = $1`, [userId]); + return c.body(null, 204); + } 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( - host(c), - `INSERT INTO dormouse_founders ("userId", name) - SELECT $1, $2 WHERE ${subscribedSql("$1::text", "$3::text[]")} - ON CONFLICT ("userId") DO UPDATE SET name = EXCLUDED.name - RETURNING "userId"`, - [userId, name, setup.founding], - ); - if (!joined.length) return c.json({ message: FOUNDERS_ONLY }, 409); - } + const joined = await accountQuery( + host(c), + `INSERT INTO dormouse_founders ("userId", name) + SELECT $1, $2 WHERE ${subscribedSql("$1::text", "$3::text[]")} + ON CONFLICT ("userId") DO UPDATE SET name = EXCLUDED.name + RETURNING "userId"`, + [userId, name, setup.founding], + ); + if (!joined.length) return c.json({ message: FOUNDERS_ONLY }, 409); return summary(c, billing, setup, changes, userId); }); }); diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index dba5a34dc..315a9d640 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -5,6 +5,7 @@ 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 { resyncDisputes } from "./billing-routes"; +import { retryClosures } from "./closure"; import { grantsAccessSql } from "./entitlement"; import { priceChanges, runNotices, usableChanges, type PriceChange } from "./notices"; import { retryWithdrawals } from "./withdrawal"; @@ -197,11 +198,11 @@ 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, then rereads a - * batch of open disputes from Stripe, then runs the subscription notices - * and the price changes they allow. Billing closed does nothing. Every - * owner, withdrawal, dispute, and notice is tried; any failure fails the - * invocation after. + * any withdrawal whose acknowledgement or settlement failed and any closure + * whose refunds failed, then rereads a batch of open disputes from Stripe, + * then runs the subscription notices and the price changes they allow. + * Billing closed does nothing. Every owner, withdrawal, closure, dispute, + * and notice is tried; any failure fails the invocation after. */ export async function resyncBilling( setup: BillingSetup | null, @@ -222,6 +223,7 @@ export async function resyncBilling( for (const { owner } of due) await billing.reconcile(owner).catch((error: unknown) => failed.push(error)); failed.push(...(await retryWithdrawals(billing, mail))); + failed.push(...(await retryClosures(billing))); failed.push(...(await resyncDisputes(billing))); failed.push(...(await runNotices(billing, setup, changes, mail))); }); diff --git a/hosted/server/closure.ts b/hosted/server/closure.ts index 4fe758851..e9ba5a627 100644 --- a/hosted/server/closure.ts +++ b/hosted/server/closure.ts @@ -1,12 +1,14 @@ // Rules: docs/specs/hosted.md -> "Account closure". -import type { Billing } from "@pgstencil/stripe"; +import { BillingError, purchaseStanding, type Billing, type BillingDB, type Stripe } from "@pgstencil/stripe"; import type { Context, Hono } from "hono"; +import { CompiledQuery, type Selectable } from "kysely"; import { bodyLimit } from "hono/body-limit"; import { readJson } from "remote-lib-common"; import { accountQuery, cookieLogin, type AccountHost } from "./account-gate"; -import { withBilling, type BillingSetup, type Plan } from "./billing"; +import { REFUND_DAYS, withBilling, type BillingSetup, type Plan } from "./billing"; import { CURRENT_STATUSES } from "./entitlement"; import { LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW } from "./policy-constants"; +import { refundPaidInvoices } from "./withdrawal"; /** What one request's account deployment provides to account closure. */ export interface ClosureHost extends AccountHost { @@ -52,12 +54,83 @@ async function endSubscriptions(billing: Billing, owner: string): Promise< return current.map(({ id }) => id); } +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Whether closing refunds paid invoice `invoice` of subscription `ended`, + * which the closure ended: its first payment while it ended within + * `REFUND_DAYS` of starting, as `purchaseStanding` counts a refund, or a + * yearly plan's renewal paid within `REFUND_DAYS` of the end. A monthly + * renewal never; the 30-day guarantee does not cover one. + */ +function closureRefunds(billing: Billing, ended: Selectable, invoice: Stripe.Invoice) { + if (invoice.billing_reason === "subscription_create") + return purchaseStanding(ended, { refundDays: REFUND_DAYS }) === "refund-window"; + const paidAt = invoice.status_transitions?.paid_at; + const plan = billing.planOf(ended.price_id); + return ( + invoice.billing_reason === "subscription_cycle" && + (plan === "yearly" || plan === "founding") && + !!paidAt && + new Date(ended.ended_at!).getTime() < paidAt * 1000 + REFUND_DAYS * DAY_MS + ); +} + +/** + * Refunds in full what closing `owner`'s account owes on each subscription + * it canceled ({@link closureRefunds}), then stamps the closure settled. + * Safe to repeat. + */ +async function settleClosure(billing: Billing, owner: string, canceled: readonly string[]) { + for (const id of canceled) { + const ended = await billing.db + .selectFrom("subscriptions") + .selectAll() + .where("id", "=", id) + .where("owner_id", "=", owner) + .executeTakeFirst(); + if (!ended?.ended_at) throw new Error(`Closure of ${owner}: subscription ${id} has not ended`); + await refundPaidInvoices(billing, id, { kind: "closure", id }, (invoice) => closureRefunds(billing, ended, invoice)); + } + await billing.db.executeQuery( + CompiledQuery.raw(`UPDATE public.dormouse_account_closures SET "settledAt" = $2 WHERE "userId" = $1`, [ + owner, + billing.time.now(), + ]), + ); +} + +/** How long the cron keeps retrying a closure's refunds, and how many one run finishes at most. */ +const RETRY_DAYS = 30; +const RETRY_BATCH = 20; + +/** + * The cron's pass: settles every closure in the last {@link RETRY_DAYS} + * days whose refunds failed, up to {@link RETRY_BATCH}; answers the + * failures. + */ +export async function retryClosures(billing: Billing): Promise { + const { rows } = await billing.db.executeQuery<{ userId: string; canceledSubscriptions: string[] }>( + CompiledQuery.raw( + `SELECT "userId", "canceledSubscriptions" FROM public.dormouse_account_closures + WHERE "settledAt" IS NULL AND "closedAt" > $1 ORDER BY "closedAt" LIMIT $2`, + [new Date(billing.time.now().getTime() - RETRY_DAYS * DAY_MS), RETRY_BATCH], + ), + ); + const failed: unknown[] = []; + for (const { userId, canceledSubscriptions } of rows) + await settleClosure(billing, userId, canceledSubscriptions).catch((error: unknown) => failed.push(error)); + return failed; +} + /** * The account's own closure, `POST /api/account/close`: a recent login and * `{ close: true }`, then every current subscription is canceled in Stripe - * before the account is deleted, its closure recorded in the same statement. - * Billing closed, an account still holding a current subscription is refused - * rather than deleted with it. Call before any `/api/*` catch-all. + * before the account is deleted, its closure recorded in the same statement; + * then what the closure refunds is refunded, and a failure there logged for + * the cron to finish. Billing closed, an account still holding a current + * subscription is refused rather than deleted with it. Call before any + * `/api/*` catch-all. */ export function closureRoutes(app: Hono, host: (c: Context) => ClosureHost) { const small = bodyLimit({ maxSize: 1024, onError: (c) => c.json({ message: "Request too large." }, 413) }); @@ -71,36 +144,50 @@ export function closureRoutes(app: Hono, host: (c: Context) => ClosureHost) return c.json({ message: "Confirm that you want to close your account." }, 400); const deployment = host(c); const setup = deployment.setup(); - let canceled: string[]; - if (setup) - canceled = await withBilling(setup, deployment.databaseUrl, new URL(c.req.url).origin, (billing) => - endSubscriptions(billing, userId), + /** Records the closure and deletes the account; unsettled while it canceled something. */ + const close = async (canceled: string[]) => { + // The account's rows cascade from its user; billing records, consents, + // withdrawals, disputes, and this closure have no foreign key and stay. + const [closed] = await accountQuery<{ burrows: string[] | null }>( + deployment, + `WITH burrows AS (SELECT array_agg("burrowId") AS ids FROM dormouse_relay_burrows WHERE "userId" = $1), + recorded AS ( + INSERT INTO dormouse_account_closures ("userId", "canceledSubscriptions", "settledAt") + VALUES ($1, $2, CASE WHEN cardinality($2::text[]) = 0 THEN now() END) + ), + deleted AS (DELETE FROM "user" WHERE id = $1) + SELECT (SELECT ids FROM burrows) AS burrows`, + [userId, canceled], ); - else { + for (const burrowId of closed?.burrows ?? []) + await deployment.closeBurrow(userId, burrowId).catch((error: unknown) => + console.error("Closing a closed account's Burrow socket failed; the sweep will", error), + ); + }; + if (!setup) { const [held] = await accountQuery<{ held: boolean }>( deployment, `SELECT EXISTS (SELECT FROM pgstencil_billing.subscriptions WHERE owner_id = $1 AND status = ANY ($2::text[])) AS held`, [userId, CURRENT_STATUSES], ); if (held?.held) return c.json({ message: CLOSE_UNAVAILABLE }, 503); - canceled = []; + await close([]); + return c.body(null, 204); + } + try { + await withBilling(setup, deployment.databaseUrl, new URL(c.req.url).origin, async (billing) => { + const canceled = await endSubscriptions(billing, userId); + await close(canceled); + if (canceled.length) + await settleClosure(billing, userId, canceled).catch((error: unknown) => + console.error(`Closure of ${userId} is recorded but its refunds are not complete; the cron retries them`, error), + ); + }); + } catch (error) { + // A checkout still being created, say: the account stays, and the page shows why. + if (error instanceof BillingError) return c.json({ message: error.message }, error.status as 409); + throw error; } - // The account's rows cascade from its user; billing records, consents, - // withdrawals, disputes, and this closure have no foreign key and stay. - const [closed] = await accountQuery<{ burrows: string[] | null }>( - deployment, - `WITH burrows AS (SELECT array_agg("burrowId") AS ids FROM dormouse_relay_burrows WHERE "userId" = $1), - recorded AS ( - INSERT INTO dormouse_account_closures ("userId", "canceledSubscriptions") VALUES ($1, $2) - ), - deleted AS (DELETE FROM "user" WHERE id = $1) - SELECT (SELECT ids FROM burrows) AS burrows`, - [userId, canceled], - ); - for (const burrowId of closed?.burrows ?? []) - await deployment.closeBurrow(userId, burrowId).catch((error: unknown) => - console.error("Closing a closed account's Burrow socket failed; the sweep will", error), - ); return c.body(null, 204); }); } diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts index 39d2cba5a..d0c2ca3a4 100644 --- a/hosted/server/tests/billing-routes.test.ts +++ b/hosted/server/tests/billing-routes.test.ts @@ -204,7 +204,9 @@ test("a dispute's status suspends, restores, or ends, and an inquiry changes not ).toEqual(["suspend", "suspend", "restore", "restore", "restore", "end", null, null]); }); -test("closed, every billing route answers 503 to a signed-in account, after the login gate", async ({ onTestFinished }) => { +test("closed, every billing route but leaving the founders row answers 503 to a signed-in account, after the login gate", async ({ + onTestFinished, +}) => { const f = await fixture({ closed: true }); onTestFinished(f.close); const member = await f.account(); @@ -213,7 +215,7 @@ test("closed, every billing route answers 503 to a signed-in account, after the ["POST", "/api/billing/checkout", { plan: "monthly" }], ["POST", "/api/billing/confirm", { checkout: "op_x" }], ["POST", "/api/billing/portal"], - ["PUT", "/api/billing/founder", { shown: false }], + ["PUT", "/api/billing/founder", { shown: true, name: "Ada" }], ] as const) { const response = await member.request(path, { method, body }); expect([path, response.status, await response.json()]).toEqual([path, 503, { message: CHECKOUT_CLOSED }]); @@ -221,6 +223,10 @@ test("closed, every billing route answers 503 to a signed-in account, after the } const { body, signature } = f.dev.signed(f.event("customer.updated", { id: "cus_x" })); expect((await f.webhook(body, signature)).status).toBe(503); + // Leaving needs nothing from billing. + await f.sql(`INSERT INTO dormouse_founders ("userId", name) VALUES ($1, 'Ada')`, [member.id]); + expect((await member.request("/api/billing/founder", { method: "PUT", body: { shown: false } })).status).toBe(204); + expect(await f.sql(`SELECT name FROM dormouse_founders`)).toEqual([]); }); test("checkout takes a plan and a ref, never a Price, customer, quantity, or return URL, and counts from the webhook once", async ({ @@ -493,7 +499,7 @@ test("only a founding subscription joins the founders row, under a checked name, // A rename keeps its place in the row. expect((await put(founder, { shown: true, name: "Ada Lovelace" })).status).toBe(200); expect(await f.sql(`SELECT name, "shownSince" AS since FROM dormouse_founders`)).toEqual([{ name: "Ada Lovelace", since }]); - expect((await put(founder, { shown: false })).status).toBe(200); + expect((await put(founder, { shown: false })).status).toBe(204); expect(await founders()).toEqual([]); expect((await put(founder, { shown: true, name: "Ada" })).status).toBe(200); @@ -503,7 +509,7 @@ test("only a founding subscription joins the founders row, under a checked name, expect((await put(founder, { shown: true, name: "Ada again" })).status).toBe(409); expect(await founders()).toEqual([{ userId: founder.id, name: "Ada" }]); expect(await founder.summary()).toMatchObject({ founder: { offered: false, name: "Ada" } }); - expect((await put(founder, { shown: false })).status).toBe(200); + expect((await put(founder, { shown: false })).status).toBe(204); expect(await founders()).toEqual([]); }); @@ -901,6 +907,87 @@ test("closing an account needs a recent login, cancels its subscription in Strip expect([...f.dev.checkouts.values()].at(-1)!.status).toBe("expired"); }); +test("closing within the 30-day guarantee refunds that payment in full, once, and the cron finishes a refund that failed", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + onTestFinished(() => error.mockRestore()); + const close = (who: Awaited>) => + who.request("/api/account/close", { method: "POST", body: { close: true } }); + const settled = (userId: string) => + f.sql<{ settled: boolean }>(`SELECT "settledAt" IS NOT NULL AS settled FROM dormouse_account_closures WHERE "userId" = $1`, [ + userId, + ]).then((rows) => rows[0]?.settled); + const ago = (days: number) => new Date(f.time.now().getTime() - days * DAY_MS); + + // Nothing canceled, nothing to refund: settled at once. + const plain = await f.account(); + expect((await close(plain)).status).toBe(204); + expect(await settled(plain.id)).toBe(true); + + // A founding first payment: refunded, its seat returning to the cohort. + const founder = await f.account(); + const { subscription: founding } = await f.buy(founder, "founding"); + f.paidInvoices.set("pi_founding", founding.id); + expect((await close(founder)).status).toBe(204); + expect(f.refunds).toEqual([{ paymentIntent: "pi_founding", withdrawal: null, closure: founding.id }]); + expect(await settled(founder.id)).toBe(true); + await f.deliver(); + await vi.waitFor(async () => expect(await f.metrics()).toContain("subscription.refunded founding 1")); + + // A failed refund stands recorded, and the cron finishes it once. + const member = await f.account(); + const { subscription: monthly } = await f.buy(member, "monthly"); + f.paidInvoices.set("pi_monthly", monthly.id); + f.failing.refunds = 1; + expect((await close(member)).status).toBe(204); + expect([f.refunds.length, await settled(member.id)]).toEqual([1, false]); + await resyncBilling(f.setup, f.database.url, origin, f.mail); + expect(f.refunds.at(-1)).toEqual({ paymentIntent: "pi_monthly", withdrawal: null, closure: monthly.id }); + expect(await settled(member.id)).toBe(true); + await resyncBilling(f.setup, f.database.url, origin, f.mail); + expect(f.refunds).toHaveLength(2); + + // Past 30 days: a yearly renewal paid 10 days ago is refunded, its first + // payment and a monthly renewal never are. + const yearlyMember = await f.account(); + const { subscription: yearly } = await f.buy(yearlyMember, "yearly"); + const monthlyMember = await f.account(); + const { subscription: lateMonthly } = await f.buy(monthlyMember, "monthly"); + f.time.advanceMilliseconds(400 * DAY_MS); + f.paidInvoices.set("pi_yearly_first", yearly.id); + f.paidInvoices.set("pi_yearly_renewal", yearly.id); + f.invoiceFacts.set("pi_yearly_renewal", { reason: "subscription_cycle", paidAt: ago(10) }); + f.paidInvoices.set("pi_monthly_first", lateMonthly.id); + f.paidInvoices.set("pi_monthly_renewal", lateMonthly.id); + f.invoiceFacts.set("pi_monthly_renewal", { reason: "subscription_cycle", paidAt: ago(10) }); + expect((await close(yearlyMember)).status).toBe(204); + expect((await close(monthlyMember)).status).toBe(204); + expect(f.refunds.slice(2)).toEqual([{ paymentIntent: "pi_yearly_renewal", withdrawal: null, closure: yearly.id }]); + expect([await settled(yearlyMember.id), await settled(monthlyMember.id)]).toEqual([true, true]); +}); + +test("closing answers billing's refusal, such as a checkout still being created, and keeps the account", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.account(); + await f.sql(`INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at) VALUES ($1, 'x@example.test', now())`, [ + member.id, + ]); + await f.sql( + `INSERT INTO pgstencil_billing.checkouts (id, owner_id, plan, price_id, trial_days, status, created_at, expires_at) + VALUES ('creating', $1, 'monthly', $2, 0, 'pending', now(), now() + interval '1 hour')`, + [member.id, f.setup.monthly], + ); + const refused = await member.request("/api/account/close", { method: "POST", body: { close: true } }); + expect([refused.status, await refused.json()]).toEqual([409, { message: "Checkout is still being created. Retry shortly." }]); + expect(await f.sql(`SELECT id FROM "user" WHERE id = $1`, [member.id])).toEqual([{ id: member.id }]); +}); + test("with billing closed, closing deletes an account without a subscription and refuses one still holding one", async ({ onTestFinished, }) => { diff --git a/hosted/server/tests/stripe-fake.ts b/hosted/server/tests/stripe-fake.ts index 06e4d038e..252237882 100644 --- a/hosted/server/tests/stripe-fake.ts +++ b/hosted/server/tests/stripe-fake.ts @@ -11,13 +11,14 @@ export const CHANGED_PRICES = { monthly: "price_dev_monthly_next", yearly: "pric export const CHANGED_USD = { monthly: 12, yearly: 120 } as const; /** - * StripeDev behind a proxy that adds what billing's refund, dispute, and - * 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. + * StripeDev behind a proxy that adds what billing's refund, dispute, + * withdrawal, and closure 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, why each was billed and when it was paid, 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, undefined, { @@ -45,12 +46,16 @@ export async function stripeFake(time: Time, random: RandomSource) { const disputes = new Map(); /** PaymentIntent id → the subscription whose invoice it paid. */ const paidInvoices = new Map(); + /** PaymentIntent id → why its invoice was billed, and when it was paid; else a first payment, paid at no known time. */ + const invoiceFacts = new Map(); /** Each cancellation billing asked for: the subscription and its comment. */ const cancels: { subscription: string; comment: string | null }[] = []; /** Customer id → its billing address's country; none, no address. */ const countries = new Map(); - /** Each refund made: its PaymentIntent and its metadata's withdrawal. */ - const refunds: { paymentIntent: string; withdrawal: string | null }[] = []; + /** Each refund made: its PaymentIntent and its metadata's withdrawal, or closure when it names one. */ + const refunds: { paymentIntent: string; withdrawal: string | null; closure?: string }[] = []; + /** How many times billing read a customer, and a Price. */ + const reads = { customers: 0, prices: 0 }; /** Refund and cancellation requests to fail, before any is made, with a Stripe outage. */ const failing = { refunds: 0, cancels: 0 }; const server = createServer(async (req, res) => { @@ -73,6 +78,7 @@ export async function stripeFake(time: Time, random: RandomSource) { } const customer = /^\/v1\/customers\/([^/]+)$/.exec(url.pathname); if (customer && req.method === "GET") { + reads.customers++; const found = dev.customers.get(customer[1]!); if (!found) return json(404, { error: { message: "No such customer" } }); const country = countries.get(customer[1]!); @@ -86,7 +92,16 @@ export async function stripeFake(time: Time, random: RandomSource) { has_more: false, data: [...paidInvoices] .filter(([, subscriptionId]) => paid && subscriptionId === of) - .map(([intent]) => ({ id: `in_${intent}`, object: "invoice", status: "paid" })), + .map(([intent]) => { + const facts = invoiceFacts.get(intent); + return { + id: `in_${intent}`, + object: "invoice", + status: "paid", + billing_reason: facts?.reason ?? "subscription_create", + status_transitions: { paid_at: facts ? Math.floor(facts.paidAt.getTime() / 1000) : null }, + }; + }), }); } if (url.pathname === "/v1/refunds" && req.method === "POST") { @@ -100,7 +115,8 @@ export async function stripeFake(time: Time, random: RandomSource) { return json(400, { error: { type: "invalid_request_error", code: "charge_already_refunded", message: "Charge has already been refunded." }, }); - refunds.push({ paymentIntent: intent, withdrawal: body.get("metadata[dormouse_withdrawal]") }); + const closure = body.get("metadata[dormouse_closure]"); + refunds.push({ paymentIntent: intent, withdrawal: body.get("metadata[dormouse_withdrawal]"), ...(closure && { closure }) }); return json(200, { id: `re_${intent}`, object: "refund", payment_intent: intent, status: "succeeded" }); } if (url.pathname === "/v1/invoice_payments" && req.method === "GET") { @@ -130,6 +146,7 @@ export async function stripeFake(time: Time, random: RandomSource) { } const price = /^\/v1\/prices\/([^/]+)$/.exec(url.pathname); if (price && req.method === "GET") { + reads.prices++; const found = prices.get(price[1]!); if (!found) return json(404, { error: { message: "No such price" } }); return json(200, { @@ -212,10 +229,12 @@ export async function stripeFake(time: Time, random: RandomSource) { origin, disputes, paidInvoices, + invoiceFacts, cancels, countries, refunds, failing, + reads, prices, moves, event, diff --git a/hosted/server/withdrawal.ts b/hosted/server/withdrawal.ts index 5f7ed7cd8..db0255ba3 100644 --- a/hosted/server/withdrawal.ts +++ b/hosted/server/withdrawal.ts @@ -1,6 +1,6 @@ // Rules: docs/specs/hosted.md -> "Billing"; docs/specs/pricing.md -> // "Checkout and entitlement". -import type { Billing } from "@pgstencil/stripe"; +import type { Billing, Stripe } from "@pgstencil/stripe"; import { escape } from "@pgstencil/auth/email"; import { CompiledQuery } from "kysely"; import type { EmailMessage, EmailSender } from "pgstencil"; @@ -192,12 +192,18 @@ export function withdrawalAcknowledgement(to: string, row: WithdrawalRow): Email }; } -/** Refunds what is left of PaymentIntent `intent` for withdrawal `id`, once however often it is asked. */ -async function refundInFull(billing: Billing, id: string, intent: string) { +/** What a refund is for, named in its metadata and its idempotency key: a withdrawal by its id, a closure by its subscription's. */ +export interface RefundFor { + kind: "withdrawal" | "closure"; + id: string; +} + +/** Refunds what is left of PaymentIntent `intent` for `refund`, once however often it is asked. */ +async function refundInFull(billing: Billing, refund: RefundFor, intent: string) { try { await billing.stripe.refunds.create( - { payment_intent: intent, reason: "requested_by_customer", metadata: { dormouse_withdrawal: id } }, - { idempotencyKey: `dormouse-withdrawal-${id}-${intent}` }, + { payment_intent: intent, reason: "requested_by_customer", metadata: { [`dormouse_${refund.kind}`]: refund.id } }, + { idempotencyKey: `dormouse-${refund.kind}-${refund.id}-${intent}` }, ); } catch (error) { // Refunded in full already, by an earlier attempt or by hand. @@ -206,6 +212,30 @@ async function refundInFull(billing: Billing, id: string, intent: string) } } +/** + * Refunds in full, for `refund`, every payment of each paid invoice of + * subscription `subscriptionId` that `covers` accepts: no use deduction, tax + * included. Safe to repeat. + */ +export async function refundPaidInvoices( + billing: Billing, + subscriptionId: string, + refund: RefundFor, + covers: (invoice: Stripe.Invoice) => boolean = () => true, +) { + const label = `${refund.kind === "withdrawal" ? "Withdrawal" : "Closure"} ${refund.id}`; + const invoices = await billing.stripe.invoices.list({ subscription: subscriptionId, status: "paid", limit: 100 }); + if (invoices.has_more) throw new Error(`${label}: more paid invoices than one page`); + for (const invoice of invoices.data.filter(covers)) { + const payments = await billing.stripe.invoicePayments.list({ invoice: invoice.id!, status: "paid", limit: 100 }); + for (const { payment } of payments.data) { + const intent = typeof payment.payment_intent === "string" ? payment.payment_intent : payment.payment_intent?.id; + if (intent) await refundInFull(billing, refund, intent); + else console.warn(`${label}: invoice ${invoice.id} has a payment Stripe cannot refund here; refund it by hand`); + } + } +} + /** * Ends withdrawal `row`'s subscription at once, without proration or a final * invoice, then refunds in full every payment its paid invoices received: @@ -226,16 +256,7 @@ async function settle(billing: Billing, row: WithdrawalRow) { }); await billing.reconcile(row.userId); } - const invoices = await billing.stripe.invoices.list({ subscription: row.subscriptionId, status: "paid", limit: 100 }); - if (invoices.has_more) throw new Error(`Withdrawal ${row.id}: more paid invoices than one page`); - for (const invoice of invoices.data) { - const payments = await billing.stripe.invoicePayments.list({ invoice: invoice.id!, status: "paid", limit: 100 }); - for (const { payment } of payments.data) { - const intent = typeof payment.payment_intent === "string" ? payment.payment_intent : payment.payment_intent?.id; - if (intent) await refundInFull(billing, row.id, intent); - else console.warn(`Withdrawal ${row.id}: invoice ${invoice.id} has a payment Stripe cannot refund here; refund it by hand`); - } - } + await refundPaidInvoices(billing, row.subscriptionId, { kind: "withdrawal", id: row.id }); } /** diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index 6f87db513..7874b5ffa 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -18,7 +18,7 @@ vi.mock("./api", async (original) => ({ getApproval: vi.fn(), approveEnrollment: vi.fn(), acceptTerms: vi.fn(), post: vi.fn(), turnOffHostedVoice: vi.fn(), getBilling: vi.fn(), startCheckout: vi.fn(), confirmCheckout: vi.fn(), - openPortal: vi.fn(), setFounder: vi.fn(), withdraw: vi.fn(), closeAccount: vi.fn(), + openPortal: vi.fn(), joinFounders: vi.fn(), leaveFounders: vi.fn(), withdraw: vi.fn(), closeAccount: vi.fn(), })); const NOW = Date.UTC(2026, 9, 21, 12); @@ -433,7 +433,7 @@ describe("billing", () => { history.replaceState(null, "", "/billing"); vi.mocked(api.getSession).mockResolvedValue(login()); vi.mocked(api.getBilling).mockResolvedValue(FOUNDING); - vi.mocked(api.setFounder).mockResolvedValue({ ...FOUNDING, founder: { offered: true, name: "Ada" } }); + vi.mocked(api.joinFounders).mockResolvedValue({ ...FOUNDING, founder: { offered: true, name: "Ada" } }); await mount(null); expect(container.querySelector("#founder-shown")!.checked).toBe(false); expect(container.querySelector("#founder-name")).toBeNull(); @@ -443,7 +443,7 @@ describe("billing", () => { expect(button("Save").disabled).toBe(true); await input("founder-name", " Ada "); await click("Save"); - expect(api.setFounder).toHaveBeenCalledWith(true, "Ada"); + expect(api.joinFounders).toHaveBeenCalledWith("Ada"); expect(text()).toContain("You are in the founders row as Ada."); }); }); @@ -472,11 +472,15 @@ describe("the plan section", () => { it("lets a lapsed founder leave the row", async () => { vi.mocked(api.getBilling).mockResolvedValue({ ...NO_PLAN, founder: { offered: false, name: "Ada" } }); - vi.mocked(api.setFounder).mockResolvedValue(NO_PLAN); + vi.mocked(api.leaveFounders).mockResolvedValue(undefined); await mount(null); expect(container.querySelector('a[href="/checkout"]')).not.toBeNull(); + vi.mocked(api.getBilling).mockResolvedValue(NO_PLAN); await click("Leave the founders row"); - expect(api.setFounder).toHaveBeenCalledWith(false, ""); + expect(api.leaveFounders).toHaveBeenCalled(); + // The page reads the plan again, with the name gone. + expect(text()).toContain("You left the founders row."); + expect(text()).not.toContain("Leave the founders row"); }); it("sends an account with no plan, enrolling, to checkout in another tab once it is open", async () => { @@ -532,7 +536,7 @@ describe("withdrawal and closure", () => { await mount(null); await click("Close account…"); expect(container.querySelector("dialog[aria-labelledby=close-title]")!.open).toBe(true); - for (const said of ["Your Founding plan ends now instead of on", "Closing does not withdraw", "cancel renewal in Manage billing"]) + for (const said of ["Your Founding plan ends now instead of on", "closing refunds that payment in full", "Closing does not withdraw", "cancel renewal in Manage billing"]) expect(text()).toContain(said); expect(api.closeAccount).not.toHaveBeenCalled(); vi.mocked(api.getSession).mockResolvedValue(null); diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index b96d014c6..c5b2c860c 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -24,7 +24,8 @@ import { PriceChangedError, removeComputer, removePasskey, - setFounder, + joinFounders, + leaveFounders, social, startCheckout, turnOffHostedVoice, @@ -459,10 +460,16 @@ export function App({ }); const manageBilling = () => act("portal", openPortal); const saveFounder = (shown: boolean, name: string) => - act("founder", async () => { - setBilling(await setFounder(shown, name)); - setNotice({ text: shown ? `You are in the founders row as ${name}.` : "You left the founders row." }); - }); + shown + ? act("founder", async () => { + setBilling(await joinFounders(name)); + setNotice({ text: `You are in the founders row as ${name}.` }); + }) + : // Leaving answers no plan: the page reads it again. + mutate("founder", async () => { + await leaveFounders(); + setNotice({ text: "You left the founders row." }); + }); const withdraw = (subscription: string) => act("withdraw", async () => { setBilling(await withdrawRequest(subscription)); diff --git a/hosted/src/Closure.tsx b/hosted/src/Closure.tsx index d42ec8285..a8fecbd21 100644 --- a/hosted/src/Closure.tsx +++ b/hosted/src/Closure.tsx @@ -10,8 +10,9 @@ const day = (iso: string) => new Date(iso).toLocaleDateString(); /** * What closing does to the account's plan, said before it is confirmed * (docs/specs/hosted.md -> "Account closure"): when access ends, that - * renewal stops, which refunds stay open, and the alternative of cancelling - * renewal. Null while the plan could not be read, so closing waits for it. + * renewal stops, which payment closing refunds, that withdrawal stays open, + * and the alternative of cancelling renewal. Null while the plan could not + * be read, so closing waits for it. */ export function closureTerms(billing: BillingSummary | "closed" | null): ReactNode[] | null { if (billing === null) return null; @@ -19,14 +20,11 @@ export function closureTerms(billing: BillingSummary | "closed" | null): ReactNo const name = planName(billing.plan); return [ billing.until && billing.active - ? `Your ${name} plan ends now instead of on ${day(billing.until)}, and it never renews. The time left is not refunded.` + ? `Your ${name} plan ends now instead of on ${day(billing.until)}, and it never renews.` : `Your ${name} plan ends now, and it never renews.`, - <> - {billing.plan === "monthly" - ? "Within 30 days of your first payment" - : "Within 30 days of your first payment or of a yearly renewal"} - , you can still ask for a full refund after closing. - , + billing.plan === "monthly" + ? "If your first payment was in the last 30 days, closing refunds it in full, including tax. Otherwise the time left is not refunded." + : "If your first payment, or a yearly renewal, was in the last 30 days, closing refunds that payment in full, including tax. Otherwise the time left is not refunded.", ...(billing.withdrawal ? [ <> diff --git a/hosted/src/api.ts b/hosted/src/api.ts index f67001c06..42d5e155b 100644 --- a/hosted/src/api.ts +++ b/hosted/src/api.ts @@ -296,9 +296,14 @@ export async function closeAccount() { ); if (!response.ok) throw await refused(response); } -/** Joins the founders row under `name`, or leaves it. */ -export async function setFounder(shown: boolean, name: string): Promise { - const response = await billing("/founder", sendJson("PUT", shown ? { shown, name } : { shown })); +/** Joins the founders row under `name`, answering the plan. */ +export async function joinFounders(name: string): Promise { + const response = await billing("/founder", sendJson("PUT", { shown: true, name })); if (!response.ok) throw await billingRefused(response); return (await response.json()) as BillingSummary; } +/** Leaves the founders row; billing need not be open. */ +export async function leaveFounders() { + const response = await billing("/founder", sendJson("PUT", { shown: false })); + if (!response.ok) throw await billingRefused(response); +} diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 09aa7d1f4..802668577 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": 6200, + "docs/specs/hosted.md": 6300, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4200, diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index b4cd7402e..703764b67 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -32,7 +32,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

You buy Hosted subscriptions from DiffPlug LLC. We use Stripe Billing, Stripe-hosted Checkout, and Stripe’s customer portal to process payments and manage subscriptions. Stripe collects payment details, billing addresses, and applicable tax information. It processes information on our behalf and for its own purposes, such as fraud prevention and legal compliance, as described in Stripe’s privacy policy. Dormouse does not receive or store your full card number.

We receive subscription and billing information from Stripe: customer and subscription identifiers linked to your account, the billing email, the plan and price (including a founding cohort, if any), status, billing-period dates, cancellation status, billing-event records, whether a payment dispute is open, and which Dormouse link led to a purchase (such as the voice setting), which also travels to Stripe with the checkout. Stripe’s dashboard and billing records may also make your billing address, tax information, invoices, and limited payment-method details, such as card brand and last four digits, available to us for support, accounting, refunds, and disputes.

When you start a checkout, we record which plan and price it offered, which version of the terms you agreed to, that you agreed to automatic renewal, and when.

-

If you withdraw from a subscription on your billing page, we record which subscription, its plan and price, your billing country as Stripe holds it, when you submitted the withdrawal, and when we emailed its acknowledgement and completed the refund. To decide whether to offer the withdrawal, we read your billing country and email from Stripe. If you close your account, we record when, and which subscriptions closing canceled.

+

If you 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, which subscriptions closing canceled, and when we completed any refund closing gave.

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, if your sign-in provider supplied one, your profile picture on the Hosted page. The box starts unticked, and you can untick it at any time. We fetch each picture from your provider and serve it from our own site, so a visitor’s browser never asks your sign-in provider for it. 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.

diff --git a/website/src/pages/Terms.tsx b/website/src/pages/Terms.tsx index 8511abd71..fa0195f37 100644 --- a/website/src/pages/Terms.tsx +++ b/website/src/pages/Terms.tsx @@ -68,7 +68,7 @@ export const TERMS_SECTIONS: PolicySection[] = [

We will give at least 30 days’ advance notice before we permanently discontinue Hosted, materially reduce your paid service during a prepaid term, or make a material change to these terms, unless an urgent legal or security requirement prevents it; in that case we will notify you as soon as reasonably possible.

We refund the unused portion of your prepaid subscription, including the corresponding tax we collected, if we discontinue Hosted, if we end your paid access for a reason other than your material breach, or if, during a prepaid term, we materially reduce your paid service or materially change these terms and you choose to end your subscription instead of accepting the change. The refund runs from when paid access ends, and the 30-day guarantee does not limit it. Discontinuation also stops renewals. The Relay is source-available and its self-hosting guide is published if you want to operate your own Relay; self-hosting does not replace your refund.

We may restrict, suspend, or terminate an account to address abuse, a security issue, a legal requirement, or a material breach of these terms. Where practical, we will explain the reason and provide a way to contact us; urgent protective action may come first.

-

You may stop using the service at any time. You can close your account with “Close account” on your account page, or, if you cannot sign in, by emailing support@dormouse.sh; we may verify that you own the account, but do not require you to recover access to it. Closing cancels your subscription at once: paid access ends and no renewal follows. Before you confirm, we explain when paid access will end and which refunds remain available to you. Closing does not by itself refund the rest of a paid period, but it does not end the 30-day guarantee or a withdrawal right, which you can still use by emailing us. You can instead cancel renewal and keep access through the paid period. Email us to ask us to review a restriction. Information is retained or deleted as described in the privacy policy and as required by law.

+

You may stop using the service at any time. You can close your account with “Close account” on your account page, or, if you cannot sign in, by emailing support@dormouse.sh; we may verify that you own the account, but do not require you to recover access to it. Closing cancels your subscription at once: paid access ends and no renewal follows. Before you confirm, we explain when paid access will end and which refunds remain available to you. If you close within 30 days of your subscription’s first payment, or of a yearly renewal payment, we refund that payment in full, including tax we collected on it, without your having to ask. Otherwise closing does not by itself refund the rest of a paid period. Closing does not end a withdrawal right, or the 30-day guarantee for an earlier payment, which you can still use by emailing us. You can instead cancel renewal and keep access through the paid period. Email us to ask us to review a restriction. Information is retained or deleted as described in the privacy policy and as required by law.

}, { id: "responsibility", title: "Warranties and responsibility", body: <>

Consumers retain their mandatory rights to services that conform to the contract and are provided with reasonable care and skill, and any applicable remedies such as correction, a price reduction, or a refund. The qualifications below apply only where the law permits them.

From 5ecb757832e67a22a1a7034141d6bd21be1bff96 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 18:44:15 -0700 Subject: [PATCH 4/4] Hosted: fewer Stripe reads, unstarved notices, dev parity, and shared cohort work kept alive - The billing page reuses a customer's contact Stripe gave for 10 minutes, per isolate, instead of reading it on every load in a subscription's first 21 days; a withdrawal and every email still read it fresh. A price change's Prices, once confirmed, are not read again: Stripe never changes a Price's amount. Both caches key on a Stripe client now kept one per key for the isolate's life. - sendNotices takes never-tried notices first, then the least recently tried, so notices that keep failing no longer starve newer ones past their deadlines. - The account Worker and the dev server mount one accountRoutes and run one accountCron, so dev gains the founders row, terms acceptance, the admin metrics, and the cron it lacked. - The cohort snapshot and avatar loads that later requests share run under the starting request's waitUntil, so a client going away cannot cancel I/O other requests are waiting on. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 4 +- docs/specs/security-hosted.md | 2 +- hosted/server/account-app.ts | 31 ++++-------- hosted/server/account-routes.ts | 41 ++++++++++++++++ hosted/server/billing-routes.ts | 2 +- hosted/server/billing.ts | 21 ++++++-- hosted/server/cohort-routes.ts | 18 ++++++- hosted/server/dev.ts | 19 ++++---- hosted/server/notices.ts | 16 ++++-- hosted/server/tests/billing-routes.test.ts | 57 ++++++++++++++++++++-- hosted/server/withdrawal.ts | 33 ++++++++++--- 11 files changed, 190 insertions(+), 54 deletions(-) create mode 100644 hosted/server/account-routes.ts diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index b1d302963..a6ec62501 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -340,13 +340,13 @@ Source of truth: `hosted/server/metric-labels.ts`; `recordMetric` and `authMetri ## Development and release -**Must run local development with `dor tool hosted` inside Dormouse.** Its loopback `http://localhost:` origin serves Vite and Node auth on a disposable development database, with the account's Natural Voice, Relay, and billing routes but never the voice origin's; billing runs on StripeDev, which takes no card. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. **It also runs the relay Worker on that database, with this origin as `ACCOUNT_ORIGIN`** (`docs/specs/one-time.md` -> "Dev loop"). +**Must run local development with `dor tool hosted` inside Dormouse.** Its loopback `http://localhost:` origin serves Vite and Node auth on a disposable development database, with the Worker's account routes (`accountRoutes`) and cron, but never the voice origin's; billing runs on StripeDev, which takes no card. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. **It also runs the relay Worker on that database, with this origin as `ACCOUNT_ORIGIN`** (`docs/specs/one-time.md` -> "Dev loop"). **Must verify the three production Worker bundles and run the consumer's integration suite before release.** Only test entries inject deterministic Better Auth. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. **Must keep production, test, and preview databases and credentials separate.** The development and preview entries are email-only. Production configuration and operator steps live in `hosted/README.md`. -Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `hosted/server/dev.ts`; `build` in `hosted/package.json`. Pinned by `hosted/server/tests/boundary.test.ts`. +Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `hosted/server/dev.ts`; `accountRoutes` and `accountCron` in `hosted/server/account-routes.ts`; `build` in `hosted/package.json`. Pinned by `hosted/server/tests/boundary.test.ts`. ## PR previews diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 83ea2542f..5dec4df7d 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -12,7 +12,7 @@ | **Each Hosted Worker answers only its own origin and runs only its own code.** No third-party script, analytics, or framing; sign-in cookies stay on the account origin, and its session never reaches browser storage. | One-time link, Hosted phone | `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts` | - **FAIL IF** `S77` a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`. -- **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieLogin` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale). +- **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieLogin` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `accountRoutes` in `hosted/server/account-routes.ts`, and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale). - **FAIL IF** `S79` the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET` or `RELAY_VAPID_PRIVATE_KEY`, or the relay or voice entry imports Better Auth, or `WORKERS` in `hosted/scripts/workers.mjs` provisions either an auth secret; inspect `hosted/server/bindings.ts` and each entry's import graph. - **FAIL IF** `S281` any bindings mapper but the production account's passes a `STRIPE_*` binding; `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is anything but an account Worker secret, a Wrangler `vars` entry or a preview config included; or the relay or voice entry's import graph reaches `@pgstencil/stripe`, `stripe`, or `hosted/server/billing.ts`. Inspect `hosted/server/bindings.ts`, `hosted/wrangler.jsonc`, `hosted/scripts/preview.mjs`, and each entry's import graph. - **FAIL IF** `S80` authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or account (Better Auth) session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. diff --git a/hosted/server/account-app.ts b/hosted/server/account-app.ts index a2a6283af..080fa4097 100644 --- a/hosted/server/account-app.ts +++ b/hosted/server/account-app.ts @@ -1,19 +1,12 @@ import type { Context, ExecutionContext, Hono } from "hono"; import { postmarkEmail } from "@pgstencil/auth/postmark"; import type { EmailSender } from "pgstencil"; -import { cookieAdmin, openAuthRead, ownPageRead } from "./account-gate"; -import { billingSetup, resyncBilling } from "./billing"; -import { billingRoutes, type BillingHost } from "./billing-routes"; +import { openAuthRead, ownPageRead } from "./account-gate"; +import { accountCron, accountRoutes, type AccountRoutesHost } from "./account-routes"; +import { billingSetup } from "./billing"; import type { AccountEnv } from "./bindings"; -import { closureRoutes, type ClosureHost } from "./closure"; -import { cohortRoutes } from "./cohort-routes"; import { accountRules } from "./headers"; -import { ADMIN_METRICS_PATH } from "./metric-labels"; -import { readMetrics } from "./metrics"; -import { relayAccountRoutes, type RelayAccountHost } from "./relay-account"; import { relayRoom } from "./relay-room-contract"; -import { termsRoutes } from "./terms"; -import { voiceAccountRoutes } from "./voice"; import { workerApp } from "./worker-app"; /** @@ -54,28 +47,22 @@ export function accountApp( headers.delete("cookie"); return fetchAuth(new Request(c.req.raw, { headers }), c.env, c.executionCtx); }); - const host = (c: Context<{ Bindings: AccountEnv }>): RelayAccountHost & BillingHost & ClosureHost => ({ + accountRoutes(app, (c: Context<{ Bindings: AccountEnv }>): AccountRoutesHost => ({ databaseUrl: c.env.HYPERDRIVE.connectionString, auth: (request) => fetchAuth(request, c.env, c.executionCtx), approveLimit: c.env.RELAY_APPROVE_LIMIT, closeBurrow: (userId, burrowId) => relayRoom(c.env.RELAY_ROOM, userId).closeBurrow(burrowId), setup: () => billingSetup(c.env), mail: () => mail(c.env), - }); - termsRoutes(app, host); - voiceAccountRoutes(app, host); - relayAccountRoutes(app, host); - billingRoutes(app, host); - closureRoutes(app, host); - cohortRoutes(app, (c) => ({ databaseUrl: c.env.HYPERDRIVE.connectionString, setup: () => billingSetup(c.env) })); - app.get(ADMIN_METRICS_PATH, cookieAdmin(host), async (c) => - c.json(await readMetrics(c.env.HYPERDRIVE.connectionString)), - ); + })); }, // Every desktop since v1.2.0 opens `/account` by a hard-coded path // (`ACCOUNT_PAGE_PATH`), which this SPA fallback answers. fallback: (app) => app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw)), scheduled: (_controller, env) => - resyncBilling(billingSetup(env), env.HYPERDRIVE.connectionString, env.APP_ORIGIN, () => mail(env)), + accountCron( + { databaseUrl: env.HYPERDRIVE.connectionString, setup: () => billingSetup(env), mail: () => mail(env) }, + env.APP_ORIGIN, + ), }); } diff --git a/hosted/server/account-routes.ts b/hosted/server/account-routes.ts new file mode 100644 index 000000000..94f643fa8 --- /dev/null +++ b/hosted/server/account-routes.ts @@ -0,0 +1,41 @@ +import type { Context, Hono } from "hono"; +import type { EmailSender } from "pgstencil"; +import { cookieAdmin } from "./account-gate"; +import { resyncBilling, type BillingSetup } from "./billing"; +import { billingRoutes, type BillingHost } from "./billing-routes"; +import { closureRoutes, type ClosureHost } from "./closure"; +import { cohortRoutes } from "./cohort-routes"; +import { ADMIN_METRICS_PATH } from "./metric-labels"; +import { readMetrics } from "./metrics"; +import { relayAccountRoutes, type RelayAccountHost } from "./relay-account"; +import { termsRoutes } from "./terms"; +import { voiceAccountRoutes } from "./voice"; + +/** What one request's account deployment provides to its routes past auth. */ +export type AccountRoutesHost = RelayAccountHost & BillingHost & ClosureHost; + +/** + * Every account route past auth, as the account Worker and the local + * development server both mount them: terms acceptance, Natural Voice on or + * off, the Relay's account routes, billing, account closure, the founders + * row, and the admin's metrics. Call before any `/api/*` catch-all. + */ +export function accountRoutes(app: Hono, host: (c: Context) => AccountRoutesHost) { + termsRoutes(app, host); + voiceAccountRoutes(app, host); + relayAccountRoutes(app, host); + billingRoutes(app, host); + closureRoutes(app, host); + // Never `auth`: the public routes resolve no login. + cohortRoutes(app, (c) => { + const deployment = host(c); + return { databaseUrl: deployment.databaseUrl, setup: () => deployment.setup() }; + }); + app.get(ADMIN_METRICS_PATH, cookieAdmin(host), async (c) => c.json(await readMetrics(host(c).databaseUrl))); +} + +/** The account's Cron Trigger, every 10 minutes in the Worker and the development server alike: billing's resync. */ +export const accountCron = ( + deployment: { databaseUrl: string; setup(): BillingSetup | null; mail(): EmailSender }, + origin: string, +) => resyncBilling(deployment.setup(), deployment.databaseUrl, origin, () => deployment.mail()); diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts index ab1616bcb..b779344b2 100644 --- a/hosted/server/billing-routes.ts +++ b/hosted/server/billing-routes.ts @@ -136,7 +136,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) // A contract withdrawn from once is never offered again. const offer = held && account?.withdrawn?.subscription !== status.subscription!.id - ? await withdrawalOffer(billing, userId, status.subscription) + ? await withdrawalOffer(billing, userId, status.subscription, { cached: true }) : null; return c.json({ plan: held ? status.plan : null, diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index 315a9d640..35b05b2a9 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -122,6 +122,21 @@ export async function foundingCohort(billing: Billing, founding: readonly return openCohort(gross, net); } +/** + * One Stripe client per key for the isolate's life: it holds no I/O of its + * own, and the caches keyed by it (`withdrawalOffer`'s contact, a price + * change's checked Prices) then outlive a request. + */ +const clients = new Map(); +function stripeClient(secretKey: string): Stripe { + let client = clients.get(secretKey); + if (!client) { + client = new Stripe(secretKey, { httpClient: Stripe.createFetchHttpClient(), maxNetworkRetries: 1 }); + clients.set(secretKey, client); + } + return client; +} + // The system clock: a test bundle's injected `Date` scopes it to the test's. const time = new SystemTime(); const random = new SecureRandom(); @@ -156,11 +171,7 @@ export async function withBilling( }); const billing = new Billing( db, - setup.stripe ?? - new Stripe(setup.secretKey, { - httpClient: Stripe.createFetchHttpClient(), - maxNetworkRetries: 1, - }), + setup.stripe ?? stripeClient(setup.secretKey), setup.time ?? time, random, { diff --git a/hosted/server/cohort-routes.ts b/hosted/server/cohort-routes.ts index 7b3ad3988..c783ececd 100644 --- a/hosted/server/cohort-routes.ts +++ b/hosted/server/cohort-routes.ts @@ -169,9 +169,23 @@ async function snapshot(host: CohortHost, setup: BillingSetup, origin: string): */ export function cohortRoutes(app: Hono, host: (c: Context) => CohortHost, now = () => Date.now()) { let cached: { at: number; value: Promise } | null = null; + /** + * Keeps `work`, which later requests await too, running past the end of + * the request that started it: a Worker cancels a request's outstanding + * I/O when its client goes away, which would leave every waiter hanging. + * Node, the development server and tests, has no execution context. + */ + const shared = (c: Context, work: Promise) => { + let ctx: { waitUntil(promise: Promise): void } | undefined; + try { + ctx = c.executionCtx; + } catch {} + ctx?.waitUntil(work.catch(() => {})); + return work; + }; const current = (c: Context, setup: BillingSetup) => { if (!cached || now() - cached.at >= COHORT_CACHE_MS) { - const value = snapshot(host(c), setup, new URL(c.req.url).origin); + const value = shared(c, snapshot(host(c), setup, new URL(c.req.url).origin)); const entry = { at: now(), value }; cached = entry; // A failure is never reused: the next request tries again. @@ -193,7 +207,7 @@ export function cohortRoutes(app: Hono, host: (c: Context) => CohortHost, n const token = c.req.param("token"); const entry = setup && TOKEN.test(token) ? (await current(c, setup)).avatars.get(token) : undefined; if (!entry) return c.json({ message: "Not found." }, 404); - entry.loaded ??= loadAvatar(entry.source).catch(() => null); + entry.loaded ??= shared(c, loadAvatar(entry.source).catch(() => null)); const avatar = await entry.loaded; if (!avatar) return c.json({ message: "Not found." }, 404); return c.body(avatar.bytes, 200, { "content-type": avatar.type }); diff --git a/hosted/server/dev.ts b/hosted/server/dev.ts index c256e638c..c931a38ac 100644 --- a/hosted/server/dev.ts +++ b/hosted/server/dev.ts @@ -9,15 +9,13 @@ import { createServer as createViteServer, type ViteDevServer } from "vite"; import { createAuthApp } from "@pgstencil/auth/better-auth"; import { developmentDatabase } from "pgstencil/database"; import { EmailDev, SecureRandom, SystemTime } from "pgstencil"; +import { accountCron, accountRoutes, type AccountRoutesHost } from "./account-routes"; import { stripeDevBilling } from "./billing-dev"; -import { BILLING_WEBHOOK_PATH, billingRoutes, type BillingHost } from "./billing-routes"; -import { closureRoutes, type ClosureHost } from "./closure"; +import { BILLING_WEBHOOK_PATH } from "./billing-routes"; import { authMetricHooks } from "./metrics"; import { authPolicy } from "./policy"; import { migrations } from "./migrations"; import { allowedDevRequest } from "./dev-host-guard"; -import { relayAccountRoutes, type RelayAccountHost } from "./relay-account"; -import { voiceAccountRoutes } from "./voice"; // Bind first, then derive the origin from the port actually bound, so an unset // PORT runs beside another checkout's server. `localhost`, not `127.0.0.1`: it @@ -78,7 +76,7 @@ const stripe = await stripeDevBilling( stripe.dev.setWebhookTarget(origin + BILLING_WEBHOOK_PATH); const app = new Hono(); // Built once, so the approval limiter counts across requests. -const host: RelayAccountHost & BillingHost & ClosureHost = { +const host: AccountRoutesHost = { databaseUrl, auth: (request: Request) => auth.app.fetch(request), approveLimit: devLimit(10), @@ -90,10 +88,12 @@ const host: RelayAccountHost & BillingHost & ClosureHost = { // A withdrawal's acknowledgement lands in the local inbox beside sign-in codes. mail: () => email, }; -voiceAccountRoutes(app, () => host); -relayAccountRoutes(app, () => host); -billingRoutes(app, () => host); -closureRoutes(app, () => host); +accountRoutes(app, () => host); +// The Worker's Cron Trigger (`hosted/wrangler.jsonc`), every 10 minutes here too. +const cron = setInterval( + () => void accountCron(host, origin).catch((error: unknown) => console.error("The account cron failed", error)), + 10 * 60_000, +); // No speak: a Hosted build speaks only at the fixed voice origin, so no // Dormouse build could reach one here. app.all("*", (c) => auth.app.fetch(c.req.raw)); @@ -166,6 +166,7 @@ function devLimit(perMinute: number): RateLimit { for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, async () => { stopping = true; + clearInterval(cron); relay.kill("SIGTERM"); server.close(); await vite.close(); diff --git a/hosted/server/notices.ts b/hosted/server/notices.ts index fa97955cf..272b73c5a 100644 --- a/hosted/server/notices.ts +++ b/hosted/server/notices.ts @@ -1,6 +1,6 @@ // Rules: docs/specs/hosted.md -> "Billing notices"; docs/specs/pricing.md -> // "Checkout and entitlement". -import type { Billing } from "@pgstencil/stripe"; +import type { Billing, Stripe } from "@pgstencil/stripe"; import { escape } from "@pgstencil/auth/email"; import { CompiledQuery, type Kysely } from "kysely"; import type { EmailMessage, EmailSender } from "pgstencil"; @@ -102,13 +102,20 @@ export function priceUsd(setup: BillingSetup, changes: readonly PriceChange[], p const intervalOf = (plan: Plan) => (plan === "monthly" ? "month" : "year"); +/** Prices a Stripe client confirmed, by id, amount, interval, and mode: Stripe never changes a Price's amount or interval. */ +const confirmedPrices = new WeakMap>(); + /** 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); + let confirmed = confirmedPrices.get(billing.stripe); + if (!confirmed) confirmedPrices.set(billing.stripe, (confirmed = new Set())); for (const [id, usd] of [ [change.fromPriceId, change.fromUsd], [change.toPriceId, change.toUsd], ] as const) { + const key = `${id} ${usd} ${interval} ${billing.config.live}`; + if (confirmed.has(key)) continue; const price = await billing.stripe.prices.retrieve(id); if ( price.currency !== "usd" || @@ -118,6 +125,7 @@ async function verifyChange(billing: Billing, change: PriceChange) { price.livemode !== billing.config.live ) throw new Error(`Price change ${change.id}: Stripe's ${id} is not ${usd} USD every ${interval}`); + confirmed.add(key); } } @@ -219,7 +227,9 @@ export function noticeMessage(to: string, notice: Pending, plan: Plan | null, co * {@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. + * Never-tried notices go first, then the least recently tried, so ones that + * keep failing never starve newer ones past their deadlines. Answers the + * failures. */ export async function sendNotices( billing: Billing, @@ -248,7 +258,7 @@ export async function sendNotices( 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`, + ORDER BY n."attemptedAt" NULLS FIRST, n."createdAt", n.id LIMIT $5`, [ now, new Date(now.getTime() - LEASE_MS), diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts index d0c2ca3a4..d51635154 100644 --- a/hosted/server/tests/billing-routes.test.ts +++ b/hosted/server/tests/billing-routes.test.ts @@ -90,8 +90,14 @@ async function fixture({ closed = false } = {}) { ); // The account Worker's `onError`. app.onError((_error, c) => c.json({ message: "unavailable" }, 503)); - /** A public read, as the site's Worker forwards it: no cookie, no browser headers. */ - const visit = (path: string) => app.request(origin + path, { headers: { accept: "*/*" } }); + /** A public read, as the site's Worker forwards it: no cookie, no browser headers; in a Worker, with its `ctx`. */ + const visit = (path: string, ctx?: { waitUntil(promise: Promise): void }) => + app.request( + origin + path, + { headers: { accept: "*/*" } }, + undefined, + ctx && { ...ctx, passThroughOnException: () => {}, props: {} }, + ); /** A signed-in account; `email: null` is a provider-only one. */ const account = async (email: string | null = `${randomUUID()}@example.test`, emailVerified = email !== null) => { @@ -814,8 +820,15 @@ test("the online withdrawal is offered within its window, to an EEA, UK, or unkn expect([refused.status, await refused.json()]).toEqual([409, { message: WITHDRAWAL_CLOSED }]); const unknown = await f.account(); - const { subscription } = await f.buy(unknown, "monthly"); + const { session, subscription } = await f.buy(unknown, "monthly"); + expect(await unknown.summary()).toMatchObject({ withdrawal: { subscription: subscription.id } }); + // The page reuses Stripe's answer for a while; a withdrawal reads it afresh. + const reads = f.reads.customers; + f.countries.set(session.customer as string, "US"); expect(await unknown.summary()).toMatchObject({ withdrawal: { subscription: subscription.id } }); + expect(f.reads.customers).toBe(reads); + expect((await unknown.request("/api/billing/withdraw", { method: "POST", body: { subscription: subscription.id } })).status).toBe(409); + f.countries.delete(session.customer as string); // Past the window, it is closed, and the summary stops offering it. await f.sql( `UPDATE pgstencil_billing.subscriptions SET started_at = now() - ($2::int * interval '1 day') - interval '1 minute' WHERE id = $1`, @@ -1115,6 +1128,8 @@ test("a price change applies only from a renewal its notice reached at least 7 d f.prices.set(CHANGED_PRICES.monthly, { usd: CHANGED_USD.monthly, interval: "month" }); await resync(); expect([f.email.all(), f.moves]).toEqual([[], []]); + // Stripe never changes a Price's amount: one confirmed is not read again. + const priceReads = f.reads.prices; await f.sql(`DELETE FROM dormouse_billing_notices`); await f.sql(`UPDATE pgstencil_billing.subscriptions SET status = 'canceled' WHERE id = $1`, [lateSubscription.id]); @@ -1126,6 +1141,7 @@ test("a price change applies only from a renewal its notice reached at least 7 d 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(f.reads.prices).toBe(priceReads); 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]); @@ -1196,6 +1212,29 @@ test("a dispute's suspension and restoration are each emailed once to the billin expect(await unsent()).toEqual([{ kind: "dispute_suspended" }]); }); +test("notices that keep failing go to the back, so they never starve a newer one", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const resync = () => resyncBilling(f.setup, f.database.url, origin, f.mail).catch((error: unknown) => error); + // A full pass of notices to accounts Stripe holds no email for, then one it does. + await f.sql( + `INSERT INTO dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") + SELECT 'failing-' || n, 'nobody-' || n, 'dispute_restored', 'du_failing_' || n, $1 FROM generate_series(1, 50) n`, + [new Date(f.time.now().getTime() - DAY_MS)], + ); + const member = await f.account(); + const { session } = await f.buy(member, "monthly"); + await f.sql( + `INSERT INTO dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") VALUES ('newer', $1, 'dispute_restored', 'du_newer', $2)`, + [member.id, f.time.now()], + ); + await resync(); + expect(f.email.all()).toEqual([]); + f.time.advanceMilliseconds(10 * 60 * 1000); + await resync(); + expect(f.email.all().map((mail) => mail.to[0])).toEqual([f.dev.customers.get(session.customer as string)!.email]); +}); + /** `count` paid founders at founding step `step`, started 40 days ago, straight into the synchronized rows. */ const fillCohort = (f: Awaited>, step: number, count: number) => f.sql( @@ -1283,6 +1322,18 @@ test("the public founders row shows only opted-in founders still subscribed, wit }); }); +test("in a Worker, the request that starts a shared answer keeps it running past its own end", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const waited: Promise[] = []; + const ctx = { waitUntil: (promise: Promise) => void waited.push(promise) }; + expect((await f.visit(COHORT_ENDPOINT, ctx)).status).toBe(200); + expect(waited).toHaveLength(1); + // Reused from the cache, it starts nothing. + expect((await f.visit(COHORT_ENDPOINT, ctx)).status).toBe(200); + expect(waited).toHaveLength(1); +}); + test("the public founders row shows at most MAX_SHOWN_FOUNDERS, the earliest first, and counts the rest", async ({ onTestFinished, }) => { diff --git a/hosted/server/withdrawal.ts b/hosted/server/withdrawal.ts index db0255ba3..d37b760f0 100644 --- a/hosted/server/withdrawal.ts +++ b/hosted/server/withdrawal.ts @@ -72,18 +72,37 @@ export interface WithdrawalRow { settledAt: Date | null; } -/** The Stripe customer's billing email and address country, or null when Stripe cannot say. */ -export async function billingContact(billing: Billing, ownerId: string) { +/** How long the billing page reuses a customer's contact Stripe answered, per isolate and Stripe client. */ +const CONTACT_CACHE_MS = 10 * 60 * 1000; + +type Contact = { email: string | null; country: string | null } | null; +const contacts = new WeakMap>(); + +/** + * The Stripe customer's billing email and address country, or null when + * Stripe cannot say. `cached` reuses an answer Stripe gave within + * {@link CONTACT_CACHE_MS}, for the billing page's reads; what sends mail or + * refuses a withdrawal reads Stripe fresh. + */ +export async function billingContact(billing: Billing, ownerId: string, { cached = false } = {}): Promise { const account = await billing.db .selectFrom("accounts") .select("customer_id") .where("owner_id", "=", ownerId) .executeTakeFirst(); if (!account?.customer_id) return null; + const now = billing.time.now().getTime(); + let known = contacts.get(billing.stripe); + if (!known) contacts.set(billing.stripe, (known = new Map())); + const hit = known.get(account.customer_id); + if (cached && hit && now - hit.at < CONTACT_CACHE_MS) return hit.contact; try { const customer = await billing.stripe.customers.retrieve(account.customer_id); - if (customer.deleted) return null; - return { email: customer.email ?? null, country: customer.address?.country?.toUpperCase() ?? null }; + const contact = customer.deleted + ? null + : { email: customer.email ?? null, country: customer.address?.country?.toUpperCase() ?? null }; + known.set(account.customer_id, { at: now, contact }); + return contact; } catch (error) { console.warn(`Reading Stripe customer ${account.customer_id} failed`, error); return null; @@ -94,18 +113,20 @@ export async function billingContact(billing: Billing, ownerId: string) { * The online withdrawal from `subscription`, or null: offered while it is * current, within {@link WITHDRAWAL_OPEN_DAYS} of its start, and its billing * country is in {@link WITHDRAWAL_COUNTRIES} or unknown, a Stripe that cannot - * say included. Whether it was withdrawn already is the caller's. + * say included; `cached` as {@link billingContact} takes it. Whether it was + * withdrawn already is the caller's. */ export async function withdrawalOffer( billing: Billing, ownerId: string, subscription: Held | null, + { cached = false } = {}, ): Promise { if (!subscription?.started_at || !CURRENT_STATUSES.includes(subscription.status)) return null; const startedAt = new Date(subscription.started_at); const until = new Date(startedAt.getTime() + WITHDRAWAL_OPEN_DAYS * DAY_MS); if (billing.time.now() >= until) return null; - const contact = await billingContact(billing, ownerId); + const contact = await billingContact(billing, ownerId, { cached }); if (contact?.country && !WITHDRAWAL_COUNTRIES.has(contact.country)) return null; return { subscription: subscription.id,