From e0533aa5ebc99838cab7236fc92f8dc47579611e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 18:56:31 -0700 Subject: [PATCH 1/7] Hosted: one price catalog, plan names, and billing summary type shared by server and page Plan/isPlan live beside BILLING_PLANS; PLAN_NAMES and planInterval beside the Tiers; checkout's offers spread the Tiers. The server reads every Price's plan and dollars from one priceCatalog built per billing run, in place of publishedPrice, priceUsd, usableChanges, and two recognized-Price lists. GET /api/billing's body is a shared no-I/O type the route satisfies. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 2 +- docs/specs/security-hosted.md | 2 +- hosted/server/billing-routes.ts | 47 ++++++------ hosted/server/billing-summary.ts | 56 ++++++++++++++ hosted/server/billing.ts | 109 ++++++++++++++++++++-------- hosted/server/closure.ts | 4 +- hosted/server/notices.ts | 95 ++++-------------------- hosted/server/policy-constants.ts | 2 + hosted/server/tests/billing.test.ts | 3 +- hosted/server/withdrawal.ts | 30 ++------ hosted/src/App.test.tsx | 3 +- hosted/src/App.tsx | 4 +- hosted/src/Checkout.tsx | 41 ++++++----- hosted/src/Closure.tsx | 5 +- hosted/src/HostedPage.tsx | 7 +- hosted/src/Plan.tsx | 10 +-- hosted/src/Withdrawal.tsx | 6 +- hosted/src/api.ts | 4 +- hosted/src/billing.test.ts | 4 +- hosted/src/billing.ts | 84 ++------------------- hosted/src/stories/fixtures.tsx | 2 +- website/src/lib/hosted-pricing.ts | 14 +++- 22 files changed, 254 insertions(+), 280 deletions(-) create mode 100644 hosted/server/billing-summary.ts diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index a6ec62501..5cf1c38d1 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -95,7 +95,7 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 - **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, every route past the login gate 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. +- **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 `priceCatalog` does not price at `price` is expired and answers 409 `PRICE_CHANGED`. `GET` shows the latest while a subscription is held. - **Must add or rename a founders-row name (`dormouse_founders`) only while `subscribedSql` holds on the founding Prices** (else 409) and `isFounderName` accepts it; leaving always works, a rename keeps `shownSince`. Checks: `docs/specs/security-hosted.md` -> "Account boundary". - **Must verify the webhook's signature over the raw body before any write** and answer 2xx only once `webhook()` has committed, any other failure 503, which Stripe retries. - **Must end at once the subscription a fully refunded charge or a lost dispute's charge paid for**, found through its invoice, while it is the owner's current one: commented `ENDED_FOR_REFUND`, after the commit and on every delivery until ended. A partial refund ends nothing, nor does a charge for an earlier subscription or none, which is logged for the operator. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 5dec4df7d..341a4da63 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -34,7 +34,7 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t - **FAIL IF** `S283` a billing route takes a Stripe Price, customer, owner, quantity, or success or return URL from the request, acts for any owner but the cookie's login, passes Stripe an email the login has not verified, or sends a `ref` or counts a label `checkoutRef` did not produce; inspect `billingRoutes` in `hosted/server/billing-routes.ts`. - **FAIL IF** `S284` the webhook writes before `Stripe-Signature` verifies over the raw body, reads a body past `WEBHOOK_BODY_BYTES`, answers 2xx before `webhook()` has committed, accepts an event of the other mode, another API version, or a Connect account, counts a metric outside its transaction, so a redelivery counts twice, or lets a failed count fail or roll back the event; inspect `processEvent` in `hosted/server/billing-routes.ts`. - **FAIL IF** `S285` a partial refund or an open dispute ends a subscription, an open dispute leaves its account entitled, a dispute's suspension follows the event's status rather than the one Stripe returns, a full refund or a lost dispute leaves the subscription its charge paid for current once a delivery answered 2xx, or ends any other subscription, the owner's current one included; inspect `processEvent`, `disputeAction`, `subscriptionPaidBy`, and `endForRefund` in `hosted/server/billing-routes.ts`. -- **FAIL IF** `S286` checkout answers a Stripe URL without `autoRenew: true` and `terms` equal to `TERMS_VERSION`, for a checkout whose Price `publishedPrice` does not read as the request's `price`, or before its `dormouse_checkout_consents` row, naming the checkout, that version, the login, and the plan and that Price, has committed; or that table cascades from or is deleted with the account. Inspect `billingRoutes` in `hosted/server/billing-routes.ts` and `hosted/server/dormouse-migrations/016_checkout_consents.sql`. +- **FAIL IF** `S286` checkout answers a Stripe URL without `autoRenew: true` and `terms` equal to `TERMS_VERSION`, for a checkout whose Price `priceCatalog` does not price at the request's `price`, or before its `dormouse_checkout_consents` row, naming the checkout, that version, the login, and the plan and that Price, has committed; or that table cascades from or is deleted with the account. Inspect `billingRoutes` in `hosted/server/billing-routes.ts` and `hosted/server/dormouse-migrations/016_checkout_consents.sql`. - **FAIL IF** `S287` a `dormouse_founders` row is inserted or renamed for an account `subscribedSql` does not hold for on the founding Prices, or for any but the cookie's login, or its name passes without `isFounderName` in the route and the table's CHECK refusing a Cc, Cf, Zl, or Zp character, padding, nothing, or more than 64 code points. Inspect the founder route in `hosted/server/billing-routes.ts`, `subscribedSql` in `hosted/server/entitlement.ts`, `isFounderName` in `hosted/server/policy-constants.ts`, and `hosted/server/dormouse-migrations/015_founders.sql`. - **FAIL IF** `S288` `/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`. diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts index b779344b2..6dcc059b3 100644 --- a/hosted/server/billing-routes.ts +++ b/hosted/server/billing-routes.ts @@ -6,12 +6,13 @@ import { bodyLimit } from "hono/body-limit"; import { CompiledQuery, type Kysely } from "kysely"; import { readJson } from "remote-lib-common"; import { accountQuery, cookieLogin, type AccountHost } from "./account-gate"; -import { REFUND_DAYS, foundingCohort, publishedPrice, withBilling, type BillingSetup, type Plan } from "./billing"; +import { REFUND_DAYS, foundingCohort, withBilling, type BillingSetup, type PriceCatalog } from "./billing"; +import type { BillingSummary, Withdrawn } from "./billing-summary"; import { CURRENT_STATUSES, ENDED_STATUSES, entitledSql, subscribedSql } from "./entitlement"; import { checkoutRef, type MetricEvent } from "./metric-labels"; import { countMetricInTransaction, recordMetric } from "./metrics"; -import { BILLING_PLANS, CHECKOUT_CLOSED, PRICE_CHANGED, TERMS_VERSION, isFounderName } from "./policy-constants"; -import { priceUsd, sendNotices, type NoticeContext, type PriceChange } from "./notices"; +import { CHECKOUT_CLOSED, PRICE_CHANGED, TERMS_VERSION, isFounderName, isPlan, type Plan } from "./policy-constants"; +import { sendNotices, type NoticeContext } from "./notices"; import { completeWithdrawal, recordWithdrawal, recordedWithdrawal, withdrawalOffer } from "./withdrawal"; import { SUPPORT_EMAIL } from "../../website/src/lib/hosted-withdrawal"; import type { EmailSender } from "pgstencil"; @@ -51,8 +52,6 @@ const JSON_BODY_BYTES = 4096; const CHECKOUT_ID = /^[A-Za-z0-9_-]{1,64}$/; const SUBSCRIPTION_ID = /^sub_[A-Za-z0-9_]{1,250}$/; -const isPlan = (plan: unknown): plan is Plan => (BILLING_PLANS as readonly unknown[]).includes(plan); - /** * What a dispute's current status does to its account's subscription: an * open one suspends it, one closed in the account's favor restores it, and @@ -78,14 +77,14 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) /** Runs `action` on this deployment's `Billing`: 503 while closed, a `BillingError` as its status. */ const billed = async ( c: Context, - action: (billing: Billing, setup: BillingSetup, changes: readonly PriceChange[]) => Promise, + action: (billing: Billing, setup: BillingSetup, catalog: PriceCatalog) => Promise, ) => { const deployment = host(c); const setup = deployment.setup(); if (!setup) return c.json({ message: CHECKOUT_CLOSED }, 503); try { - return await withBilling(setup, deployment.databaseUrl, new URL(c.req.url).origin, (billing, changes) => - action(billing, setup, changes), + return await withBilling(setup, deployment.databaseUrl, new URL(c.req.url).origin, (billing, catalog) => + action(billing, setup, catalog), ); } catch (error) { if (error instanceof BillingError) return c.json({ message: error.message }, error.status as 400); @@ -98,7 +97,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) c: Context, billing: Billing, setup: BillingSetup, - changes: readonly PriceChange[], + catalog: PriceCatalog, userId: string, ) => { const [status, [account], founding] = await Promise.all([ @@ -109,7 +108,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) founder: boolean; name: string | null; consent: { termsVersion: string; consentedAt: string } | null; - withdrawn: { subscription: string } | null; + withdrawn: Withdrawn | null; }>( host(c), // A held subscription refuses another checkout, so the latest consent is its own. @@ -140,9 +139,9 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) : null; return c.json({ plan: held ? status.plan : null, - price: held ? priceUsd(setup, changes, status.subscription!.price_id) : null, + price: held ? (catalog.prices.get(status.subscription!.price_id)?.usd ?? null) : null, active: status.access, - until: held ? status.accessUntil : null, + until: held ? (status.accessUntil?.toISOString() ?? null) : null, renews: held && !status.subscription!.cancel_at_period_end, entitled: account?.entitled === true, suspended: account?.suspended === true, @@ -157,11 +156,11 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) account?.withdrawn && (!held || account.withdrawn.subscription === status.subscription!.id) ? account.withdrawn : null, - }); + } satisfies BillingSummary); }; app.get("/api/billing", gate, (c) => - billed(c, (billing, setup, changes) => summary(c, billing, setup, changes, c.get("login").userId)), + billed(c, (billing, setup, catalog) => summary(c, billing, setup, catalog, c.get("login").userId)), ); app.post("/api/billing/checkout", limit(JSON_BODY_BYTES), gate, async (c) => { @@ -170,7 +169,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) const plan = body?.plan; if (!isPlan(plan)) return c.json({ message: "Choose monthly, yearly, or founding." }, 400); const ref = checkoutRef(body?.ref); - return billed(c, async (billing, setup) => { + return billed(c, async (billing, _setup, catalog) => { // `price` is what the page showed beside the renewal the buyer agreed to. if (typeof body?.terms !== "string" || body.autoRenew !== true || !Number.isSafeInteger(body.price)) return c.json({ message: CONSENT_REQUIRED }, 400); @@ -194,7 +193,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) .where("id", "=", id) .where("owner_id", "=", userId) .executeTakeFirstOrThrow(); - if (publishedPrice(setup, opened.price_id) !== body.price) { + if (catalog.prices.get(opened.price_id)?.usd !== body.price) { await billing.cancelCheckout(userId); return c.json({ message: PRICE_CHANGED }, 409); } @@ -216,9 +215,9 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) const checkout = (await readJson<{ checkout?: unknown }>(c))?.checkout; if (typeof checkout !== "string" || !CHECKOUT_ID.test(checkout)) return c.json({ message: "Checkout not found." }, 404); - return billed(c, async (billing, setup, changes) => { + return billed(c, async (billing, setup, catalog) => { await billing.confirmCheckout(userId, checkout); - return summary(c, billing, setup, changes, userId); + return summary(c, billing, setup, catalog, userId); }); }); @@ -236,7 +235,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) const subscription = (await readJson<{ subscription?: unknown }>(c))?.subscription; if (typeof subscription !== "string" || !SUBSCRIPTION_ID.test(subscription)) return c.json({ message: "Choose the contract to withdraw from." }, 400); - return billed(c, async (billing, setup, changes) => { + return billed(c, async (billing, setup, catalog) => { let row = await recordedWithdrawal(billing, userId, subscription); if (!row) { const { subscription: held, plan } = await billing.status(userId); @@ -247,7 +246,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) await completeWithdrawal(billing, () => host(c).mail(), row).catch((error: unknown) => console.error(`Withdrawal ${row.id} is recorded but not complete; the cron retries it`, error), ); - return summary(c, billing, setup, changes, userId); + return summary(c, billing, setup, catalog, userId); }); }); @@ -265,7 +264,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) await accountQuery(host(c), `DELETE FROM dormouse_founders WHERE "userId" = $1`, [userId]); return c.body(null, 204); } - return billed(c, async (billing, setup, changes) => { + return billed(c, async (billing, setup, catalog) => { const joined = await accountQuery( host(c), `INSERT INTO dormouse_founders ("userId", name) @@ -275,7 +274,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) [userId, name, setup.founding], ); if (!joined.length) return c.json({ message: FOUNDERS_ONLY }, 409); - return summary(c, billing, setup, changes, userId); + return summary(c, billing, setup, catalog, userId); }); }); @@ -285,8 +284,8 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) const signature = c.req.header("stripe-signature"); if (!signature) return c.json({ message: "Missing signature." }, 400); const body = await c.req.text(); - return billed(c, async (billing, setup, changes) => { - await processEvent(billing, body, signature, () => host(c).mail(), { setup, changes, origin: new URL(c.req.url).origin }); + return billed(c, async (billing, _setup, catalog) => { + await processEvent(billing, body, signature, () => host(c).mail(), { catalog, origin: new URL(c.req.url).origin }); return c.json({ received: true }); }); }); diff --git a/hosted/server/billing-summary.ts b/hosted/server/billing-summary.ts new file mode 100644 index 000000000..d63dd7ca7 --- /dev/null +++ b/hosted/server/billing-summary.ts @@ -0,0 +1,56 @@ +// Rules: docs/specs/hosted.md -> "Billing". No I/O: the account page's bundle +// imports this as well as the account Worker. +import type { Plan } from "./policy-constants"; + +/** `GET /api/billing`, and every billing mutation's answer. */ +export interface BillingSummary { + /** The subscription Stripe still holds, paid up or not; null without one. */ + plan: Plan | null; + /** What that subscription charges per interval, in whole US dollars. */ + price: number | null; + /** Whether that subscription grants access now. */ + active: boolean; + until: string | null; + renews: boolean; + /** Whether the account has Hosted now, by its subscription or a comp. */ + entitled: boolean; + /** A payment dispute is open, which pauses the subscription. */ + suspended: boolean; + /** The founding cohort on sale; null once founding has closed. */ + founding: { cohort: number; seatsLeft: number } | null; + /** `offered` while a founding subscription grants access; `name` while shown in the founders row. */ + founder: { offered: boolean; name: string | null }; + /** What the account agreed to when it bought the held subscription. */ + consent: { termsVersion: string; consentedAt: string } | null; + /** The EEA or UK online withdrawal from the held subscription, while it is open. */ + withdrawal: WithdrawalOffer | null; + /** + * The account's latest withdrawal, kept on the page as its acknowledgement: + * the server sends it only while no plan is held or it is the held plan's. + */ + withdrawn: Withdrawn | null; +} + +/** The held subscription's online withdrawal. */ +export interface WithdrawalOffer { + /** The contract: the Stripe subscription it ends. */ + subscription: string; + startedAt: string; + /** When the online withdrawal closes. */ + until: string; + /** Where the acknowledgement is emailed; null when Stripe could not say. */ + email: string | null; +} + +/** A recorded withdrawal. */ +export interface Withdrawn { + reference: string; + subscription: string; + plan: string; + startedAt: string; + submittedAt: string; + /** Its acknowledgement was emailed. */ + acknowledged: boolean; + /** The subscription ended and every payment for it was refunded. */ + settled: boolean; +} diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index 35b05b2a9..35776f634 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -1,5 +1,6 @@ // Rules: docs/specs/hosted.md -> "Billing". import { Billing, BillingError, Stripe, type BillingDB } from "@pgstencil/stripe"; +import { CompiledQuery, type Kysely } from "kysely"; 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"; @@ -7,12 +8,9 @@ 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 { runNotices } from "./notices"; import { retryWithdrawals } from "./withdrawal"; -import { BILLING_PLANS, BILLING_RETURN_PATH } from "./policy-constants"; - -/** The plans checkout sells, by the names `status()` and a checkout use. */ -export type Plan = (typeof BILLING_PLANS)[number]; +import { BILLING_RETURN_PATH, type Plan } from "./policy-constants"; /** A purchase that ends within this many days of starting is a refund: its seat returns to its cohort. */ export const REFUND_DAYS = 30; @@ -67,15 +65,73 @@ export function billingSetup(env: BillingEnv): BillingSetup | null { }; } -/** - * What `priceId` charges per interval, in whole US dollars, as the published - * prices say each configured Price does; null for a Price not configured. - */ -export function publishedPrice(setup: BillingSetup, priceId: string): number | null { - if (priceId === setup.monthly) return LIST_MONTHLY; - if (priceId === setup.yearly) return LIST_ANNUAL; - const step = setup.founding.indexOf(priceId); - return step === -1 ? null : FOUNDING_LADDER[step]!; +/** A scheduled list price change, as the operator recorded it in `dormouse_price_changes`. */ +export interface PriceChange { + id: string; + plan: "monthly" | "yearly"; + fromPriceId: string; + toPriceId: string; + fromUsd: number; + toUsd: number; + effectiveAt: Date; +} + +/** One statement on `db`, answering its rows. */ +export const run = (db: Pick, "executeQuery">, text: string, values: readonly unknown[]) => + db.executeQuery(CompiledQuery.raw(text, [...values])).then((result) => result.rows); + +/** Every scheduled price change, oldest first. */ +const priceChanges = (db: Kysely) => + run( + db, + `SELECT id, plan, "fromPriceId", "toPriceId", "fromUsd", "toUsd", "effectiveAt" + FROM public.dormouse_price_changes ORDER BY "createdAt", id`, + [], + ); + +/** Every Price billing recognizes, by id, and the price changes it acts on or refuses. */ +export interface PriceCatalog { + /** + * Each recognized Price's plan, and what it charges per interval in whole + * US dollars: a configured Price as the published prices say, another as + * its usable change says, its new Price before its old. + */ + prices: ReadonlyMap; + /** `plan`'s Prices, configured first. */ + recognized(plan: Plan): string[]; + usable: readonly PriceChange[]; + /** A change naming a founding Price, whose lock no change may touch, or a Price another plan's configuration or change already holds: refused whole. */ + refused: readonly PriceChange[]; +} + +/** The catalog `setup` configures, with the price changes `changes` schedules. */ +export function priceCatalog(setup: BillingSetup, changes: readonly PriceChange[]): PriceCatalog { + const prices = new Map([ + [setup.monthly, { plan: "monthly", usd: LIST_MONTHLY }], + [setup.yearly, { plan: "yearly", usd: LIST_ANNUAL }], + ...setup.founding.map((price, step) => [price, { plan: "founding", usd: FOUNDING_LADDER[step]! }] as const), + ]); + const owner = new Map([...prices].map(([price, { plan }]) => [price, plan])); + const usable: PriceChange[] = []; + const refused: PriceChange[] = []; + for (const change of changes) { + const ids = [change.fromPriceId, change.toPriceId]; + if (ids.some((price) => (owner.get(price) ?? change.plan) !== change.plan)) refused.push(change); + else { + for (const price of ids) owner.set(price, change.plan); + usable.push(change); + } + } + for (const side of ["to", "from"] as const) + for (const change of usable) + if (!prices.has(change[`${side}PriceId`])) + prices.set(change[`${side}PriceId`], { plan: change.plan, usd: change[`${side}Usd`] }); + return { + prices, + recognized: (plan) => [...prices].filter(([, price]) => price.plan === plan).map(([id]) => id), + usable, + refused, + }; } /** The open cohort and its seats left; null once founding has closed. */ @@ -143,7 +199,7 @@ const random = new SecureRandom(); /** * Runs `action` with a `Billing` over `databaseUrl` for the account origin - * `origin`, and the scheduled price changes, closing its connections after. + * `origin`, and the price catalog, closing its connections after. * No trial; DiffPlug is the merchant of record on Stripe Billing, never * Managed Payments, with Stripe Tax on every Checkout; founding checkout * offers the open cohort's Price and refuses once founding has closed. @@ -154,21 +210,12 @@ export async function withBilling( setup: BillingSetup, databaseUrl: string, origin: string, - action: (billing: Billing, changes: readonly PriceChange[]) => Promise, + action: (billing: Billing, catalog: PriceCatalog) => Promise, ): Promise { const db = connectDatabase(databaseUrl); try { - const changes = await priceChanges(db); - const { usable } = usableChanges(setup, changes); - const prices = (plan: "monthly" | "yearly") => ({ - recognized: [ - ...new Set([ - setup[plan], - ...usable.filter((change) => change.plan === plan).flatMap((change) => [change.fromPriceId, change.toPriceId]), - ]), - ], - offer: () => setup[plan], - }); + const catalog = priceCatalog(setup, await priceChanges(db)); + const prices = (plan: "monthly" | "yearly") => ({ recognized: catalog.recognized(plan), offer: () => setup[plan] }); const billing = new Billing( db, setup.stripe ?? stripeClient(setup.secretKey), @@ -179,7 +226,7 @@ export async function withBilling( monthly: prices("monthly"), yearly: prices("yearly"), founding: { - recognized: setup.founding, + recognized: catalog.recognized("founding"), async offer(billing) { const open = await foundingCohort(billing as Billing, setup.founding); if (!open) throw new BillingError("Founding is closed.", 409); @@ -196,7 +243,7 @@ export async function withBilling( returnPath: BILLING_RETURN_PATH, }, ); - return await action(billing, changes); + return await action(billing, catalog); } finally { await db.destroy(); } @@ -230,13 +277,13 @@ export async function resyncBilling( [RESYNC_OWNERS], ); const failed: unknown[] = []; - await withBilling(setup, databaseUrl, origin, async (billing, changes) => { + await withBilling(setup, databaseUrl, origin, async (billing, catalog) => { 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))); + failed.push(...(await runNotices(billing, catalog, 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 e9ba5a627..6db1a177f 100644 --- a/hosted/server/closure.ts +++ b/hosted/server/closure.ts @@ -5,9 +5,9 @@ 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 { REFUND_DAYS, withBilling, type BillingSetup, type Plan } from "./billing"; +import { REFUND_DAYS, withBilling, type BillingSetup } from "./billing"; import { CURRENT_STATUSES } from "./entitlement"; -import { LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW } from "./policy-constants"; +import { LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW, type Plan } from "./policy-constants"; import { refundPaidInvoices } from "./withdrawal"; /** What one request's account deployment provides to account closure. */ diff --git a/hosted/server/notices.ts b/hosted/server/notices.ts index 272b73c5a..c4062cd19 100644 --- a/hosted/server/notices.ts +++ b/hosted/server/notices.ts @@ -2,12 +2,12 @@ // "Checkout and entitlement". 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"; +import { PLAN_NAMES, planInterval } from "../../website/src/lib/hosted-pricing"; import { DIFFPLUG_POSTAL_ADDRESS, SUPPORT_EMAIL } from "../../website/src/lib/hosted-withdrawal"; -import { publishedPrice, type BillingSetup, type Plan } from "./billing"; -import { BILLING_RETURN_PATH } from "./policy-constants"; -import { PLAN_NAMES, billingContact } from "./withdrawal"; +import { run, type PriceCatalog, type PriceChange } from "./billing"; +import { BILLING_RETURN_PATH, type Plan } from "./policy-constants"; +import { billingContact } from "./withdrawal"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -41,73 +41,12 @@ const LEASE_MS = 5 * 60 * 1000; /** How many notices one pass sends, and how many price changes it applies, at most. */ const BATCH = 50; -/** A scheduled list price change, as the operator recorded it in `dormouse_price_changes`. */ -export interface PriceChange { - id: string; - plan: "monthly" | "yearly"; - fromPriceId: string; - toPriceId: string; - fromUsd: number; - toUsd: number; - effectiveAt: Date; -} - -type Db = Pick, "executeQuery">; - -const run = (db: Db, text: string, values: unknown[]) => - db.executeQuery(CompiledQuery.raw(text, values)).then((result) => result.rows); - -/** Every scheduled price change, oldest first. */ -export const priceChanges = (db: Db) => - run( - db, - `SELECT id, plan, "fromPriceId", "toPriceId", "fromUsd", "toUsd", "effectiveAt" - FROM public.dormouse_price_changes ORDER BY "createdAt", id`, - [], - ); - -/** - * The changes billing may act on, and the ones it refuses: a change that - * names a founding Price, whose lock no change may touch, or a Price another - * plan's configuration or change already holds, is refused whole. - */ -export function usableChanges(setup: BillingSetup, changes: readonly PriceChange[]) { - const owner = new Map([ - [setup.monthly, "monthly"], - [setup.yearly, "yearly"], - ...setup.founding.map((price) => [price, "founding"] as [string, string]), - ]); - const usable: PriceChange[] = []; - const refused: PriceChange[] = []; - for (const change of changes) { - const prices = [change.fromPriceId, change.toPriceId]; - if (prices.some((price) => (owner.get(price) ?? change.plan) !== change.plan)) refused.push(change); - else { - for (const price of prices) owner.set(price, change.plan); - usable.push(change); - } - } - return { usable, refused }; -} - -/** What `priceId` charges per interval in whole US dollars: published, or a change's; null when neither says. */ -export function priceUsd(setup: BillingSetup, changes: readonly PriceChange[], priceId: string): number | null { - return ( - publishedPrice(setup, priceId) ?? - changes.find((change) => change.toPriceId === priceId)?.toUsd ?? - changes.find((change) => change.fromPriceId === priceId)?.fromUsd ?? - null - ); -} - -const intervalOf = (plan: Plan) => (plan === "monthly" ? "month" : "year"); - /** 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); + const interval = planInterval(change.plan); let confirmed = confirmedPrices.get(billing.stripe); if (!confirmed) confirmedPrices.set(billing.stripe, (confirmed = new Set())); for (const [id, usd] of [ @@ -143,8 +82,7 @@ interface Pending { /** What composing a notice reads beyond its row. */ export interface NoticeContext { - setup: BillingSetup; - changes: readonly PriceChange[]; + catalog: PriceCatalog; /** The account origin, whose billing page a notice links. */ origin: string; } @@ -152,11 +90,11 @@ export interface NoticeContext { const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }); /** The email for notice `notice`, to the billing email `to`. */ -export function noticeMessage(to: string, notice: Pending, plan: Plan | null, context: NoticeContext): EmailMessage { +function noticeMessage(to: string, notice: Pending, plan: Plan | null, context: NoticeContext): EmailMessage { const billingPage = new URL(BILLING_RETURN_PATH, context.origin).href; const name = plan ? PLAN_NAMES[plan] : "Dormouse Hosted"; - const interval = plan ? intervalOf(plan) : null; - const usd = notice.priceId ? priceUsd(context.setup, context.changes, notice.priceId) : null; + const interval = plan ? planInterval(plan) : null; + const usd = notice.priceId ? (context.catalog.prices.get(notice.priceId)?.usd ?? null) : null; const on = notice.renewsAt ? DAY.format(notice.renewsAt) : ""; const cancel = `To stop it renewing, cancel before ${on} in Manage billing on your billing page: ${billingPage}. You keep Hosted until ${on}. If you cannot sign in, email ${SUPPORT_EMAIL} and we will cancel it for you.`; const yearlyRefund = `Ask within 30 days of a yearly renewal payment and we refund it in full, including tax.`; @@ -182,7 +120,7 @@ export function noticeMessage(to: string, notice: Pending, plan: Plan | null, co ]; break; case "price_change": { - const change = context.changes.find((candidate) => candidate.id === notice.priceChangeId)!; + const change = context.catalog.usable.find((candidate) => candidate.id === notice.priceChangeId)!; subject = `Your Dormouse Hosted price changes on ${on}`; paragraphs = [ `The price of your ${name} subscription changes from $${change.fromUsd} to $${change.toUsd} a ${interval}, plus any applicable tax, starting with your renewal on ${on}. Nothing changes before then.`, @@ -357,13 +295,12 @@ async function applyPriceChanges(billing: Billing, verified: readonly stri */ export async function runNotices( billing: Billing, - setup: BillingSetup, - changes: readonly PriceChange[], + catalog: PriceCatalog, mail: () => EmailSender, ): Promise { const failed: unknown[] = []; const now = billing.time.now(); - const { usable, refused } = usableChanges(setup, changes); + const { usable, refused } = catalog; for (const change of refused) failed.push(new Error(`Price change ${change.id} names a founding Price or another plan's; it is ignored`)); // Checked against Stripe only while someone is still on its old Price. @@ -379,10 +316,6 @@ export async function runNotices( () => verified.push(change.id), (error: unknown) => failed.push(error), ); - const prices = (plan: "monthly" | "yearly") => [ - setup[plan], - ...usable.filter((change) => change.plan === plan).flatMap((change) => [change.fromPriceId, change.toPriceId]), - ]; // A yearly term's renewal notice is its annual reminder too; a monthly // plan's is due before each renewal that ends a year of the subscription. await run( @@ -397,7 +330,7 @@ export async function runNotices( AND s.period_end > $1::timestamptz AND s.period_end <= $1::timestamptz + make_interval(days => $4) AND (s.price_id = ANY ($2::text[]) OR (s.price_id = ANY ($3::text[]) AND term.months >= 12 AND mod(term.months, 12) = 0)) ON CONFLICT DO NOTHING`, - [now, [...prices("yearly"), ...setup.founding], prices("monthly"), RENEWAL_NOTICE_DAYS], + [now, [...catalog.recognized("yearly"), ...catalog.recognized("founding")], catalog.recognized("monthly"), RENEWAL_NOTICE_DAYS], ); // A price change's notice, for the first renewal on or after its date that // is still far enough off for the notice to count. @@ -413,7 +346,7 @@ export async function runNotices( ON CONFLICT DO NOTHING`, [now, verified, PRICE_SEND_BY_DAYS, PRICE_NOTICE_DAYS], ); - failed.push(...(await sendNotices(billing, mail, { setup, changes: usable, origin: billing.config.origin }, { verified }))); + failed.push(...(await sendNotices(billing, mail, { catalog, origin: billing.config.origin }, { verified }))); failed.push(...(await applyPriceChanges(billing, verified))); return failed; } diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts index fd26d2e67..127ee28dc 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -53,6 +53,8 @@ export const PRICE_CHANGED = "The price has changed. Check the new price and agr // The plans checkout sells, by the names a checkout request, `status()`, and // the billing metrics use. export const BILLING_PLANS = ["monthly", "yearly", "founding"] as const; +export type Plan = (typeof BILLING_PLANS)[number]; +export const isPlan = (plan: unknown): plan is Plan => (BILLING_PLANS as readonly unknown[]).includes(plan); // A name the founders row shows: 1–64 code points, none a control, format, or // line or paragraph separator, without padding. The account page checks it diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts index b2e1eae7b..1985f5f7c 100644 --- a/hosted/server/tests/billing.test.ts +++ b/hosted/server/tests/billing.test.ts @@ -6,7 +6,8 @@ import { BillingError } from "@pgstencil/stripe"; import { createTestContext } from "pgstencil/testing"; import { queryDatabase } from "pgstencil/postgres"; import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER } from "../../../website/src/lib/hosted-pricing"; -import { billingSetup, openCohort, resyncBilling, withBilling, type Plan } from "../billing"; +import { billingSetup, openCohort, resyncBilling, withBilling } from "../billing"; +import type { Plan } from "../policy-constants"; import { DEV_FOUNDING_PRICES, stripeDevBilling } from "../billing-dev"; import { ADMIN_EMAIL, entitled, entitledSql } from "../entitlement"; import { migrations } from "../migrations"; diff --git a/hosted/server/withdrawal.ts b/hosted/server/withdrawal.ts index d37b760f0..4106e0542 100644 --- a/hosted/server/withdrawal.ts +++ b/hosted/server/withdrawal.ts @@ -4,9 +4,10 @@ import type { Billing, Stripe } from "@pgstencil/stripe"; import { escape } from "@pgstencil/auth/email"; import { CompiledQuery } from "kysely"; import type { EmailMessage, EmailSender } from "pgstencil"; -import { HOSTED_MONTHLY, HOSTED_YEARLY } from "../../website/src/lib/hosted-pricing"; +import { PLAN_NAMES } from "../../website/src/lib/hosted-pricing"; import { DIFFPLUG_POSTAL_ADDRESS, SUPPORT_EMAIL, WITHDRAWAL_STATEMENT } from "../../website/src/lib/hosted-withdrawal"; -import type { Plan } from "./billing"; +import type { WithdrawalOffer } from "./billing-summary"; +import type { Plan } from "./policy-constants"; import { CURRENT_STATUSES, ENDED_STATUSES } from "./entitlement"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -26,7 +27,7 @@ export const WITHDRAWAL_OPEN_DAYS = 21; * and Gibraltar, offered rather than ruled out. A customer with no country * is offered it too. */ -export const WITHDRAWAL_COUNTRIES: ReadonlySet = new Set([ +const WITHDRAWAL_COUNTRIES: ReadonlySet = new Set([ ...["AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU"], ...["LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK"], ...["AX", "GF", "GP", "MF", "MQ", "RE", "YT"], @@ -40,25 +41,10 @@ export const ENDED_FOR_WITHDRAWAL = "dormouse: EEA or UK withdrawal"; /** How long the cron keeps retrying a withdrawal's acknowledgement or settlement. */ const RETRY_DAYS = 30; -/** Each plan's name, as the emails billing sends name it. */ -export const PLAN_NAMES: Record = { - monthly: HOSTED_MONTHLY.name, - yearly: HOSTED_YEARLY.name, - founding: "Founding", -}; - type Held = NonNullable["status"]>>["subscription"]>; -/** The online withdrawal the billing page offers for the held subscription. */ -export interface WithdrawalOffer { - subscription: string; - startedAt: string; - /** When the online withdrawal closes. */ - until: string; - /** Where the acknowledgement goes: the Stripe customer's email, null when Stripe could not say. */ - email: string | null; - country: string | null; -} +/** The online withdrawal the billing page offers for the held subscription, and the billing country it was offered for. */ +type Offer = WithdrawalOffer & { country: string | null }; /** A recorded withdrawal, as `dormouse_withdrawals` holds it. */ export interface WithdrawalRow { @@ -121,7 +107,7 @@ export async function withdrawalOffer( ownerId: string, subscription: Held | null, { cached = false } = {}, -): Promise { +): 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); @@ -161,7 +147,7 @@ export async function recordWithdrawal( ownerId: string, plan: Plan, subscription: Held, - offer: WithdrawalOffer, + offer: Offer, ): Promise { await run( billing, diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index 7874b5ffa..b1fc222f2 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; import * as api from "./api"; import type { Enrollment } from "./enrollment"; -import type { BillingSummary, CheckoutIntent } from "./billing"; +import type { BillingSummary } from "../server/billing-summary"; +import type { CheckoutIntent } from "./billing"; import { ENROLLMENT_POLL_INTERVAL_S, PRICE_CHANGED, TERMS_VERSION } from "../server/policy-constants"; import { NOT_ENTITLED_ERROR } from "../../remote-lib-common/src/remote/wire.ts"; import { WITHDRAWAL_STATEMENT } from "../../website/src/lib/hosted-withdrawal"; diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index c5b2c860c..6aa2d718d 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -41,10 +41,12 @@ import { ENROLLMENT_POLL_INTERVAL_S, LOGIN_FRESH_AGE_MS, TERMS_VERSION, + type Plan, } from "../server/policy-constants"; import { useDocsThemedBody } from "dormouse-lib/site/docs-paint"; import { HostedPage, SUBSCRIBE_URL, type View } from "./HostedPage"; -import { forgetCheckout, type BillingSummary, type CheckoutIntent, type Plan } from "./billing"; +import type { BillingSummary } from "../server/billing-summary"; +import { forgetCheckout, type CheckoutIntent } from "./billing"; import { takeEnrollment, type Enrollment } from "./enrollment"; import { afterRead, diff --git a/hosted/src/Checkout.tsx b/hosted/src/Checkout.tsx index ce3a274b6..80e6c481f 100644 --- a/hosted/src/Checkout.tsx +++ b/hosted/src/Checkout.tsx @@ -6,8 +6,10 @@ import { LINK_CLASS, MUTED_TEXT_CLASS, } from "dormouse-lib/site/docs-tokens"; -import { TERMS_VERSION, VOICE_DAILY_CAP } from "../server/policy-constants"; -import { planOffers, type BillingSummary, type Plan, type PlanOffer } from "./billing"; +import { TERMS_VERSION, VOICE_DAILY_CAP, type Plan } from "../server/policy-constants"; +import { planInterval, type Tier } from "../../website/src/lib/hosted-pricing.ts"; +import type { BillingSummary } from "../server/billing-summary"; +import { planOffers } from "./billing"; import { HELP_CLASS, PRIMARY_CLASS } from "./classes"; import { WithdrawalRight } from "./Withdrawal"; @@ -22,17 +24,18 @@ export const perInterval = (price: number, interval: "month" | "year") => `$${pr * (docs/specs/pricing.md -> "Checkout and entitlement"): price and tax, * renewal, cancellation, refunds, and material limits, for the chosen plan. */ -function Terms({ offer }: { offer: PlanOffer }) { - const every = offer.interval === "year" ? "every year" : "every month"; +function Terms({ offer }: { offer: Tier }) { + const interval = planInterval(offer.id); + const every = interval === "year" ? "every year" : "every month"; return (