diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index a6ec62501..ac6ca27ef 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -80,7 +80,7 @@ The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` -> | Route | Credential | Success | |---|---|---| -| `GET /api/billing` | login cookie | 200 `{ plan, price, active, until, renews, entitled, suspended, founding, founder, consent, withdrawal, withdrawn }`; `plan` names a subscription Stripe still holds | +| `GET /api/billing` | login cookie | 200 `{ plan, price, active, until, renews, entitled, suspended, founding, founder, consent, withdrawal, withdrawn }`; `plan` names a subscription Stripe still holds; closed, 200 `{ closed: true }` | | `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 | @@ -92,25 +92,25 @@ 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 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. +- **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 cookie route past the login gate but `GET` and leaving the founders row answers 409 `CHECKOUT_CLOSED`, the webhook and the cohort read 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. -- **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 offer the online withdrawal only from the held subscription while `withdrawalOffer` holds**, once per subscription; else withdraw answers 409 `WITHDRAWAL_CLOSED`. A withdrawal (`dormouse_withdrawals`) is recorded with its acknowledgement, a `withdrawal` notice, then settled: its subscription ended at once (`ENDED_FOR_WITHDRAWAL`), every paid invoice payment refunded in full, stamped once done and safe to repeat; the cron retries a failed settlement 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 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 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 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 unsettled 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. +Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`; `billingRoutes` and `disputeAction` in `hosted/server/billing-routes.ts`; `withdrawalOffer` and `recordWithdrawal` 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. ## Billing notices -Each notice is a `dormouse_billing_notices` row, recorded before it is sent and stamped once Postmark accepts it, one per kind and renewal or dispute. +Each notice is a `dormouse_billing_notices` row, recorded before it is sent and stamped once Postmark accepts it, one per kind and renewal, dispute, or withdrawal. | Kind | Due | Still sent while | |---|---|---| @@ -119,13 +119,14 @@ Each notice is a `dormouse_billing_notices` row, recorded before it is sent and | `price_change` | 21 days before the first renewal on or after the change's `effectiveAt` | the same, on the old Price, over 8 days ahead | | `dispute_suspended` | a dispute opens a suspension | the dispute is open, for 30 days | | `dispute_restored` | it closes in the account's favor, no other open | 30 days | +| `withdrawal` (its acknowledgement) | a withdrawal is recorded, in the same statement | 30 days | - **Must send every notice to the Stripe customer's email**, never the sign-in email, so a provider-only account gets them. - **Must record a dispute's notice in the webhook's transaction** and send it after the commit; a failed send never fails the event. - **A price change is an operator's `dormouse_price_changes` row** (`hosted/README.md`); both its Prices grant the plan once inserted. **Never act on one naming a founding Price or another plan's**, or whose Prices Stripe does not hold at its amounts, in USD, at the plan's interval. - **Must move a subscription to the new Price, without proration, only after its notice was sent at least 7 days before the renewal it names**, while Stripe still renews it then on the old Price; a later notice waits for the next renewal. Checks: `docs/specs/security-hosted.md` -> "Account boundary". -Source of truth: `runNotices` and `sendNotices` in `hosted/server/notices.ts`; `recordDispute` in `hosted/server/billing-routes.ts`; `hosted/server/dormouse-migrations/018_billing_notices.sql`. +Source of truth: `runNotices` and `sendNotices` in `hosted/server/notices.ts`; `recordDispute` in `hosted/server/billing-routes.ts`; `hosted/server/dormouse-migrations/018_billing_notices.sql` and `hosted/server/dormouse-migrations/020_withdrawal_notices.sql`. ## Account closure @@ -135,7 +136,7 @@ Source of truth: `runNotices` and `sendNotices` in `hosted/server/notices.ts`; ` Errors are JSON `{ message }`; it needs no entitlement and works with billing closed. What the page says first: `docs/specs/pricing.md` -> "Checkout and entitlement". -- **Must refuse a login older than `LOGIN_FRESH_AGE_MS`** with 403 `CLOSE_RECENT_LOGIN`, and, billing closed, an account with a current subscription row with 503 `CLOSE_UNAVAILABLE`. +- **Must 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 409 `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. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 5dec4df7d..8ae95f96f 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -34,10 +34,10 @@ 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`. +- **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; records it without its `withdrawal` notice in the same statement; stamps that notice sent before its email was, or the withdrawal 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`, `sendNotices` in `hosted/server/notices.ts`, `resyncBilling` in `hosted/server/billing.ts`, `hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql`, and `hosted/server/dormouse-migrations/020_withdrawal_notices.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`. diff --git a/hosted/server/account-gate.ts b/hosted/server/account-gate.ts index 7edda7026..b07a4b8ac 100644 --- a/hosted/server/account-gate.ts +++ b/hosted/server/account-gate.ts @@ -1,8 +1,10 @@ // Rules: docs/specs/hosted.md -> "Managed voice" and "Burrow enrollment"; // docs/specs/security-hosted.md -> "Origin boundary". import type { Context, MiddlewareHandler } from "hono"; +import { bodyLimit } from "hono/body-limit"; import { queryDatabase } from "pgstencil/postgres"; import { entitled, isAdmin } from "./entitlement"; +import { LOGIN_FRESH_AGE_MS } from "./policy-constants"; /** What one request's account deployment provides to its cookie routes. */ export interface AccountHost { @@ -42,6 +44,10 @@ export function ownPageRead(request: Request, origin: string): boolean { export const openAuthRead = (path: string) => path === "/api/auth/csrf" || path.startsWith("/api/auth/callback/"); +/** A cookie route's body cap: a larger body answers 413. */ +export const smallBody = (maxSize: number) => + bodyLimit({ maxSize, onError: (c) => c.json({ message: "Request too large." }, 413) }); + /** The login a cookie route acts for. */ export interface AccountLogin { userId: string; @@ -54,6 +60,14 @@ export interface AccountLogin { email: string | null; } +/** + * Whether `login` began within `LOGIN_FRESH_AGE_MS`, for a route that needs + * a recent login. Fails closed: a creation time missing or unreadable is not + * recent. + */ +export const isRecentLogin = (login: AccountLogin) => + Date.now() - Date.parse(String(login.createdAt)) < LOGIN_FRESH_AGE_MS; + /** * The account Worker's cookie routes' gate: a read is {@link ownPageRead}, * and a state-changing request presents exactly this `Origin` — same-site diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts index b779344b2..b19c71321 100644 --- a/hosted/server/billing-routes.ts +++ b/hosted/server/billing-routes.ts @@ -2,22 +2,20 @@ // -> "Checkout and entitlement". import { BillingError, purchaseStanding, type Billing, type BillingDB, type Stripe } from "@pgstencil/stripe"; import type { Context, Hono } from "hono"; -import { bodyLimit } from "hono/body-limit"; -import { CompiledQuery, type Kysely } from "kysely"; +import type { Kysely } from "kysely"; import { readJson } from "remote-lib-common"; -import { accountQuery, cookieLogin, type AccountHost } from "./account-gate"; -import { REFUND_DAYS, foundingCohort, publishedPrice, withBilling, type BillingSetup, type Plan } from "./billing"; -import { CURRENT_STATUSES, ENDED_STATUSES, entitledSql, subscribedSql } from "./entitlement"; +import { accountQuery, cookieLogin, smallBody, type AccountHost } from "./account-gate"; +import { foundingCohort, idOf, run, withBilling, type BillingSetup, type PriceCatalog } from "./billing"; +import type { BillingClosed, BillingSummary, Withdrawn } from "./billing-summary"; +import { CURRENT_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 { completeWithdrawal, recordWithdrawal, recordedWithdrawal, withdrawalOffer } from "./withdrawal"; +import { CHECKOUT_CLOSED, PRICE_CHANGED, REFUND_DAYS, TERMS_VERSION, isFounderName, isPlan, type Plan } from "./policy-constants"; +import { sendNotices, type NoticeContext } from "./notices"; +import { recordWithdrawal, recordedWithdrawal, settleWithdrawal, withdrawalOffer, type Contact } from "./withdrawal"; import { SUPPORT_EMAIL } from "../../website/src/lib/hosted-withdrawal"; import type { EmailSender } from "pgstencil"; -export { CHECKOUT_CLOSED, PRICE_CHANGED }; - /** What one request's account deployment provides to the billing routes. */ export interface BillingHost extends AccountHost { /** The deployment's billing setup, null while billing is closed; throws when misconfigured. */ @@ -51,8 +49,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 @@ -72,20 +68,23 @@ export function disputeAction(status: string): "suspend" | "restore" | "end" | n */ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) { const gate = cookieLogin(host); - const limit = (maxSize: number) => - bodyLimit({ maxSize, onError: (c) => c.json({ message: "Request too large." }, 413) }); - /** Runs `action` on this deployment's `Billing`: 503 while closed, a `BillingError` as its status. */ + /** + * Runs `action` on this deployment's `Billing`, a `BillingError` answered + * as its status; while billing is closed, answers `closed`, by default 409 + * `CHECKOUT_CLOSED`. + */ const billed = async ( c: Context, - action: (billing: Billing, setup: BillingSetup, changes: readonly PriceChange[]) => Promise, + action: (billing: Billing, setup: BillingSetup, catalog: PriceCatalog) => Promise, + closed: () => Response = () => c.json({ message: CHECKOUT_CLOSED }, 409), ) => { const deployment = host(c); const setup = deployment.setup(); - if (!setup) return c.json({ message: CHECKOUT_CLOSED }, 503); + if (!setup) return closed(); 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. @@ -122,7 +121,8 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) ORDER BY k."consentedAt" DESC LIMIT 1) AS consent, (SELECT json_build_object('reference', w.id, 'subscription', w."subscriptionId", 'plan', w.plan, 'startedAt', w."contractStartedAt", 'submittedAt', w."submittedAt", - 'acknowledged', w."acknowledgedAt" IS NOT NULL, 'settled', w."settledAt" IS NOT NULL) + 'acknowledged', EXISTS (SELECT FROM dormouse_billing_notices n WHERE n."withdrawalId" = w.id AND n."sentAt" IS NOT NULL), + 'settled', w."settledAt" IS NOT NULL) FROM dormouse_withdrawals w WHERE w."userId" = u.id ORDER BY w."submittedAt" DESC LIMIT 1) AS withdrawn FROM "user" u WHERE u.id = $1`, @@ -132,18 +132,18 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) ]); // A subscription Stripe still holds, paid up or not: its owner manages it // in the portal (a failed card included) and cannot check out again. - const held = !!status.subscription && !ENDED_STATUSES.includes(status.subscription.status); + const held = status.subscription && CURRENT_STATUSES.includes(status.subscription.status) ? status.subscription : null; // A contract withdrawn from once is never offered again. const offer = - held && account?.withdrawn?.subscription !== status.subscription!.id - ? await withdrawalOffer(billing, userId, status.subscription, { cached: true }) + held && account?.withdrawn?.subscription !== held.id + ? await withdrawalOffer(billing, userId, held, { cached: true }) : null; return c.json({ plan: held ? status.plan : null, - price: held ? priceUsd(setup, changes, status.subscription!.price_id) : null, + price: held ? (catalog.prices.get(held.price_id)?.usd ?? null) : null, active: status.access, - until: held ? status.accessUntil : null, - renews: held && !status.subscription!.cancel_at_period_end, + until: held ? (status.accessUntil?.toISOString() ?? null) : null, + renews: !!held && !held.cancel_at_period_end, entitled: account?.entitled === true, suspended: account?.suspended === true, founding, @@ -154,23 +154,27 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) // Only while it describes the held subscription, its settlement still // pending, or nothing is held: a plan bought after it is not withdrawn. withdrawn: - account?.withdrawn && (!held || account.withdrawn.subscription === status.subscription!.id) + account?.withdrawn && (!held || account.withdrawn.subscription === held.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), + () => c.json({ closed: true } satisfies BillingClosed), + ), ); - app.post("/api/billing/checkout", limit(JSON_BODY_BYTES), gate, async (c) => { + app.post("/api/billing/checkout", smallBody(JSON_BODY_BYTES), gate, async (c) => { const { userId, email } = c.get("login"); const body = await readJson<{ plan?: unknown; ref?: unknown; terms?: unknown; autoRenew?: unknown; price?: unknown }>(c); 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,67 +198,72 @@ 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); } // Recorded before the URL is answered, for the Price the buyer was shown. - await billing.db.executeQuery( - CompiledQuery.raw( - `INSERT INTO public.dormouse_checkout_consents ("checkoutId", "termsVersion", "userId", plan, "priceId") - VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING`, - [id, TERMS_VERSION, userId, plan, opened.price_id], - ), + await run( + billing.db, + `INSERT INTO public.dormouse_checkout_consents ("checkoutId", "termsVersion", "userId", plan, "priceId") + VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING`, + [id, TERMS_VERSION, userId, plan, opened.price_id], ); recordMetric(c, host(c).databaseUrl, "checkout.started", `${plan}:${ref}`); return c.json({ url }); }); }); - app.post("/api/billing/confirm", limit(JSON_BODY_BYTES), gate, async (c) => { + app.post("/api/billing/confirm", smallBody(JSON_BODY_BYTES), gate, async (c) => { const { userId } = c.get("login"); 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); }); }); - app.post("/api/billing/portal", limit(JSON_BODY_BYTES), gate, (c) => + app.post("/api/billing/portal", smallBody(JSON_BODY_BYTES), gate, (c) => billed(c, async (billing) => c.json({ url: await billing.portal(c.get("login").userId) })), ); // An EEA or UK consumer's withdrawal from the contract it names, which must - // be the held subscription the page offers it for. Recorded before the - // answer, it stands: an acknowledgement or a settlement that fails after is - // logged, and the cron finishes it. Naming a contract already withdrawn - // from answers that withdrawal again. - app.post("/api/billing/withdraw", limit(JSON_BODY_BYTES), gate, async (c) => { + // be the held subscription the page offers it for. Recorded with its + // acknowledgement before the answer, it stands: sending that, or settling, + // when it fails after is logged, and the cron finishes it. Naming a + // contract already withdrawn from answers that withdrawal again. + app.post("/api/billing/withdraw", smallBody(JSON_BODY_BYTES), gate, async (c) => { const { userId } = c.get("login"); const subscription = (await readJson<{ subscription?: unknown }>(c))?.subscription; if (typeof subscription !== "string" || !SUBSCRIPTION_ID.test(subscription)) return c.json({ message: "Choose the contract to withdraw from." }, 400); - return billed(c, async (billing, setup, changes) => { + return billed(c, async (billing, setup, catalog) => { + const contacts = new Map(); let row = await recordedWithdrawal(billing, userId, subscription); if (!row) { const { subscription: held, plan } = await billing.status(userId); const offer = held?.id === subscription && plan ? await withdrawalOffer(billing, userId, held) : null; - if (!offer || !held || !plan) return c.json({ message: WITHDRAWAL_CLOSED }, 409); + if (!offer || held?.id !== subscription || !plan) return c.json({ message: WITHDRAWAL_CLOSED }, 409); + // Read from Stripe just now, so its acknowledgement need not read it again. + if (offer.email) contacts.set(userId, { email: offer.email, country: offer.country }); row = await recordWithdrawal(billing, userId, plan, held, offer); } - await completeWithdrawal(billing, () => host(c).mail(), row).catch((error: unknown) => - console.error(`Withdrawal ${row.id} is recorded but not complete; the cron retries it`, error), + const context = { catalog, origin: new URL(c.req.url).origin }; + for (const error of await sendNotices(billing, () => host(c).mail(), context, { userId, contacts })) + console.error("A notice is recorded but not sent; the cron retries it", error); + await settleWithdrawal(billing, row).catch((error: unknown) => + console.error(`Withdrawal ${row.id} is recorded but not settled; the cron retries it`, error), ); - return summary(c, billing, setup, changes, userId); + return summary(c, billing, setup, catalog, userId); }); }); // The founders row: shown under `name` only while a founding subscription // 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) => { + app.put("/api/billing/founder", smallBody(JSON_BODY_BYTES), gate, async (c) => { const { userId } = c.get("login"); const body = await readJson<{ shown?: unknown; name?: unknown }>(c); const shown = body?.shown; @@ -265,7 +274,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,26 +284,27 @@ 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); }); }); // Stripe's: the raw body under its signature, 2xx only once `webhook()` has - // committed; any other failure throws to `onError`'s 503, which Stripe retries. - app.post(BILLING_WEBHOOK_PATH, limit(WEBHOOK_BODY_BYTES), async (c) => { + // committed; any other failure throws to `onError`'s 503, which Stripe + // retries, and billing closed answers 503 too, so its events wait. + app.post(BILLING_WEBHOOK_PATH, smallBody(WEBHOOK_BODY_BYTES), async (c) => { 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 }); - }); + }, () => c.json({ message: CHECKOUT_CLOSED }, 503)); }); } /** The owner of Stripe customer `customer`, or null for one pgstencil did not create. */ async function ownerOf(billing: Billing, customer: string | Stripe.Customer | Stripe.DeletedCustomer | null) { - const id = typeof customer === "string" ? customer : (customer?.id ?? null); + const id = idOf(customer); if (!id) return null; const row = await billing.db .selectFrom("accounts") @@ -332,7 +342,7 @@ async function processEvent( } await billing.webhook(body, signature, async (verified, trx) => { const count = (event: MetricEvent, label: string) => - countMetricInTransaction((text, values) => trx.executeQuery(CompiledQuery.raw(text, values)), event, label); + countMetricInTransaction((text, values) => run(trx, text, values), event, label); const completed = await billing.completedCheckout(verified, trx); if (completed && isPlan(completed.plan)) await count("checkout.completed", `${completed.plan}:${checkoutRef(completed.ref)}`); @@ -364,7 +374,7 @@ async function processEvent( * PaymentIntent do. Null when that names no one subscription. */ async function subscriptionPaidBy(billing: Billing, charge: Stripe.Charge): Promise { - const intent = typeof charge.payment_intent === "string" ? charge.payment_intent : (charge.payment_intent?.id ?? null); + const intent = idOf(charge.payment_intent); if (!intent) return null; const payments = await billing.stripe.invoicePayments.list({ payment: { type: "payment_intent", payment_intent: intent }, @@ -372,8 +382,8 @@ async function subscriptionPaidBy(billing: Billing, charge: Stripe.Charge) }); const paid = new Set( payments.data.flatMap((payment) => { - const subscription = (payment.invoice as Stripe.Invoice).parent?.subscription_details?.subscription; - return subscription ? [typeof subscription === "string" ? subscription : subscription.id] : []; + const subscription = idOf((payment.invoice as Stripe.Invoice).parent?.subscription_details?.subscription); + return subscription ? [subscription] : []; }), ); return paid.size === 1 ? [...paid][0]! : null; @@ -393,31 +403,29 @@ async function recordDispute( now: Date, ) { if (action === "suspend") - await trx.executeQuery( - CompiledQuery.raw( - `WITH opened AS ( - INSERT INTO public.dormouse_billing_disputes ("disputeId", "userId") VALUES ($1, $2) - ON CONFLICT ("disputeId") DO NOTHING RETURNING "userId") - INSERT INTO public.dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") - SELECT gen_random_uuid()::text, "userId", 'dispute_suspended', $1, $3 FROM opened - ON CONFLICT DO NOTHING`, - [disputeId, owner, now], - ), + await run( + trx, + `WITH opened AS ( + INSERT INTO public.dormouse_billing_disputes ("disputeId", "userId") VALUES ($1, $2) + ON CONFLICT ("disputeId") DO NOTHING RETURNING "userId") + INSERT INTO public.dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") + SELECT gen_random_uuid()::text, "userId", 'dispute_suspended', $1, $3 FROM opened + ON CONFLICT DO NOTHING`, + [disputeId, owner, now], ); else if (action) // The statement reads the table as it was before its own delete. - await trx.executeQuery( - CompiledQuery.raw( - `WITH closed AS ( - DELETE FROM public.dormouse_billing_disputes WHERE "disputeId" = $1 RETURNING "userId") - INSERT INTO public.dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") - SELECT gen_random_uuid()::text, "userId", 'dispute_restored', $1, $2 FROM closed - WHERE $3 AND NOT EXISTS ( - SELECT FROM public.dormouse_billing_disputes other - WHERE other."userId" = closed."userId" AND other."disputeId" <> $1) - ON CONFLICT DO NOTHING`, - [disputeId, now, action === "restore"], - ), + await run( + trx, + `WITH closed AS ( + DELETE FROM public.dormouse_billing_disputes WHERE "disputeId" = $1 RETURNING "userId") + INSERT INTO public.dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") + SELECT gen_random_uuid()::text, "userId", 'dispute_restored', $1, $2 FROM closed + WHERE $3 AND NOT EXISTS ( + SELECT FROM public.dormouse_billing_disputes other + WHERE other."userId" = closed."userId" AND other."disputeId" <> $1) + ON CONFLICT DO NOTHING`, + [disputeId, now, action === "restore"], ); } @@ -434,12 +442,11 @@ const DISPUTE_RESYNC_BATCH = 20; * 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 rows = await run<{ disputeId: string; userId: string }>( + billing.db, + `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) @@ -449,12 +456,10 @@ export async function resyncDisputes(billing: Billing): Promise 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, - ]), - ); + await run(trx, `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) { diff --git a/hosted/server/billing-summary.ts b/hosted/server/billing-summary.ts new file mode 100644 index 000000000..947925b39 --- /dev/null +++ b/hosted/server/billing-summary.ts @@ -0,0 +1,61 @@ +// 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` while billing is closed: not an outage, so not a 5xx. */ +export interface BillingClosed { + closed: true; +} + +/** `GET /api/billing` while billing is open, 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..0834d6bce 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -1,21 +1,21 @@ // 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 { connectDatabase } 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"; -import { BILLING_PLANS, BILLING_RETURN_PATH } from "./policy-constants"; +import { runNotices } from "./notices"; +import { settleWithdrawals } from "./withdrawal"; +import { BILLING_RETURN_PATH, REFUND_DAYS, type Plan } from "./policy-constants"; -/** The plans checkout sells, by the names `status()` and a checkout use. */ -export type Plan = (typeof BILLING_PLANS)[number]; +export const DAY_MS = 24 * 60 * 60 * 1000; -/** A purchase that ends within this many days of starting is a refund: its seat returns to its cohort. */ -export const REFUND_DAYS = 30; +/** The id of a Stripe reference that may arrive expanded; null without one. */ +export const idOf = (ref: string | { id: string } | null | undefined) => (typeof ref === "string" ? ref : (ref?.id ?? null)); /** A deployment's billing configuration, read from its bindings. */ export interface BillingSetup { @@ -67,15 +67,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 +201,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 +212,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 +228,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 +245,7 @@ export async function withBilling( returnPath: BILLING_RETURN_PATH, }, ); - return await action(billing, changes); + return await action(billing, catalog); } finally { await db.destroy(); } @@ -208,10 +257,10 @@ const RESYNC_OWNERS = 20; /** * The account Worker's Cron Trigger: reconciles up to {@link RESYNC_OWNERS} * random owners whose `active` or `trialing` row no longer grants access, so - * a missed renewal webhook costs a member one run at most, then finishes - * any withdrawal whose acknowledgement or settlement failed 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. + * a missed renewal webhook costs a member one run at most, then settles any + * withdrawal whose settlement failed and any closure whose refunds failed, + * then rereads a batch of open disputes from Stripe, then runs the notices, + * a withdrawal's acknowledgement among them, 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. */ @@ -222,21 +271,25 @@ export async function resyncBilling( mail: () => EmailSender, ) { if (!setup) return; - const due = await queryDatabase<{ owner: string }>( + const failed: unknown[] = []; + await withBilling( + setup, databaseUrl, - `SELECT owner_id AS owner FROM pgstencil_billing.subscriptions s - WHERE s.status IN ('active', 'trialing') AND NOT ${grantsAccessSql("s")} - GROUP BY owner_id ORDER BY random() LIMIT $1`, - [RESYNC_OWNERS], + origin, + async (billing, catalog) => { + const due = await run<{ owner: string }>( + billing.db, + `SELECT owner_id AS owner FROM pgstencil_billing.subscriptions s + WHERE s.status IN ('active', 'trialing') AND NOT ${grantsAccessSql("s")} + GROUP BY owner_id ORDER BY random() LIMIT $1`, + [RESYNC_OWNERS], + ); + for (const { owner } of due) await billing.reconcile(owner).catch((error: unknown) => failed.push(error)); + failed.push(...(await settleWithdrawals(billing))); + failed.push(...(await retryClosures(billing))); + failed.push(...(await resyncDisputes(billing))); + failed.push(...(await runNotices(billing, catalog, mail))); + }, ); - const failed: unknown[] = []; - await withBilling(setup, databaseUrl, origin, async (billing, changes) => { - for (const { owner } of due) - await billing.reconcile(owner).catch((error: unknown) => failed.push(error)); - failed.push(...(await retryWithdrawals(billing, mail))); - failed.push(...(await retryClosures(billing))); - 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 e9ba5a627..80c283338 100644 --- a/hosted/server/closure.ts +++ b/hosted/server/closure.ts @@ -1,13 +1,13 @@ // Rules: docs/specs/hosted.md -> "Account closure". 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 type { Selectable } from "kysely"; 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 { SUPPORT_EMAIL } from "../../website/src/lib/hosted-withdrawal"; +import { accountQuery, cookieLogin, isRecentLogin, smallBody, type AccountHost } from "./account-gate"; +import { DAY_MS, run, withBilling, type BillingSetup } from "./billing"; import { CURRENT_STATUSES } from "./entitlement"; -import { LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW } from "./policy-constants"; +import { RECENT_LOGIN_WINDOW, REFUND_DAYS, type Plan } from "./policy-constants"; import { refundPaidInvoices } from "./withdrawal"; /** What one request's account deployment provides to account closure. */ @@ -21,8 +21,8 @@ export interface ClosureHost extends AccountHost { /** The 403 a closure from a login older than the recent-login window gets. */ export const CLOSE_RECENT_LOGIN = `Sign in again to close your account. Closing it needs a login from the last ${RECENT_LOGIN_WINDOW}.`; -/** The 503 a closure gets when it cannot reach billing to cancel a subscription the account holds. */ -export const CLOSE_UNAVAILABLE = "Your account cannot be closed online right now. Try again later, or email support@dormouse.sh."; +/** The 409 a closure gets while billing is closed and the account holds a subscription it cannot cancel. */ +export const CLOSE_UNAVAILABLE = `Your account cannot be closed online right now. Try again later, or email ${SUPPORT_EMAIL}.`; /** The cancellation comment a subscription ended by closing its account carries. */ export const ENDED_FOR_CLOSURE = "dormouse: account closed"; @@ -54,8 +54,6 @@ 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 @@ -92,12 +90,10 @@ async function settleClosure(billing: Billing, owner: string, canceled: re 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(), - ]), - ); + await run(billing.db, `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. */ @@ -110,12 +106,11 @@ const RETRY_BATCH = 20; * 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 rows = await run<{ userId: string; canceledSubscriptions: string[] }>( + billing.db, + `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) @@ -133,13 +128,9 @@ export async function retryClosures(billing: Billing): Promise * `/api/*` catch-all. */ export function closureRoutes(app: Hono, host: (c: Context) => ClosureHost) { - const small = bodyLimit({ maxSize: 1024, onError: (c) => c.json({ message: "Request too large." }, 413) }); - - app.post("/api/account/close", small, cookieLogin(host), async (c) => { - const { userId, createdAt } = c.get("login"); - // Fails closed, as approval's check does. - if (!(Date.now() - Date.parse(String(createdAt)) < LOGIN_FRESH_AGE_MS)) - return c.json({ message: CLOSE_RECENT_LOGIN }, 403); + app.post("/api/account/close", smallBody(1024), cookieLogin(host), async (c) => { + const { userId } = c.get("login"); + if (!isRecentLogin(c.get("login"))) return c.json({ message: CLOSE_RECENT_LOGIN }, 403); if ((await readJson<{ close?: unknown }>(c))?.close !== true) return c.json({ message: "Confirm that you want to close your account." }, 400); const deployment = host(c); @@ -170,7 +161,7 @@ export function closureRoutes(app: Hono, host: (c: Context) => ClosureHost) `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); + if (held?.held) return c.json({ message: CLOSE_UNAVAILABLE }, 409); await close([]); return c.body(null, 204); } diff --git a/hosted/server/cohort-routes.ts b/hosted/server/cohort-routes.ts index c783ececd..5f0a7b57d 100644 --- a/hosted/server/cohort-routes.ts +++ b/hosted/server/cohort-routes.ts @@ -2,13 +2,12 @@ // founding card's live half". import type { Context, Hono } from "hono"; import { queryDatabase } from "pgstencil/postgres"; +import { concatBytes } from "remote-lib-common"; import { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } from "../../website/src/lib/hosted-cohorts"; import { foundingPurchases, openCohort, withBilling, type BillingSetup } from "./billing"; import { subscribedSql } from "./entitlement"; import { CHECKOUT_CLOSED } from "./policy-constants"; -export { COHORT_ENDPOINT }; - /** * What the public routes may read of a deployment: never `auth`, so nothing * here can resolve a login, whatever a request carries. @@ -21,7 +20,7 @@ export interface CohortHost { /** Where a shown founder's picture is served, under a token that is no id. */ export const FOUNDER_AVATAR_PREFIX = "/api/hosted/founders/"; -/** How long one isolate reuses an answer, and the pictures it names. */ +/** How long one isolate reuses an answer. A picture it names is kept while later answers name it from the same source. */ export const COHORT_CACHE_MS = 60_000; /** The largest picture served; a larger one draws the initial. */ @@ -40,7 +39,7 @@ const DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp);base64,\s*([A-Za-z0-9+/]+ * `https:` URL on a provider's picture host; null for anything else, which * the row draws as an initial. */ -export function avatarSource(image: string | null): string | null { +function avatarSource(image: string | null): string | null { if (!image) return null; if (image.startsWith("data:")) return DATA_IMAGE.test(image) ? image : null; let url: URL; @@ -59,7 +58,7 @@ export function avatarSource(image: string | null): string | null { type ImageType = "image/png" | "image/jpeg" | "image/gif" | "image/webp"; /** A raster image's type from its leading bytes, or null: never a provider's word for it. */ -export function imageType(bytes: Uint8Array): ImageType | null { +function imageType(bytes: Uint8Array): ImageType | null { const starts = (...prefix: number[]) => prefix.every((byte, i) => bytes[i] === byte); if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"; if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; @@ -84,13 +83,7 @@ async function readCapped(response: Response, max: number): Promise; } interface Avatar { @@ -121,36 +114,52 @@ async function loadAvatar(source: string): Promise { return type ? { bytes: bytes!, type } : null; } +/** A shown founder's picture: where it comes from, its load in progress, and the picture once one loaded. */ +interface AvatarEntry { + source: string; + loading?: Promise; + loaded?: Avatar; +} + /** One isolate's answer: the JSON, and the pictures it names by token. */ interface Snapshot { text: string; - avatars: Map }>; + avatars: Map; } /** * The open cohort, its seats left, and the founders row, read from the - * synchronized subscriptions and `dormouse_founders`. + * synchronized subscriptions and `dormouse_founders`. A picture `previous` + * loaded from the same source is kept, so a rebuilt answer fetches nothing + * again; only a loaded one, so a failed load is tried again. */ -async function snapshot(host: CohortHost, setup: BillingSetup, origin: string): Promise { - const { gross, net } = await withBilling(setup, host.databaseUrl, origin, (billing) => - foundingPurchases(billing, setup.founding), - ); - const open = openCohort(gross, net); - // Shown only while opted in and the founding subscription still holds, so - // a refund or a lapse takes the founder out of the row. - const rows = await queryDatabase<{ name: string; image: string | null; token: string }>( +async function snapshot(host: CohortHost, setup: BillingSetup, origin: string, previous?: Snapshot): Promise { + const [{ gross, net }, rows] = await withBilling( + setup, host.databaseUrl, - `SELECT f.name, u.image, encode(sha256(convert_to(f."userId", 'UTF8')), 'hex') AS token - FROM dormouse_founders f JOIN "user" u ON u.id = f."userId" - WHERE ${subscribedSql('f."userId"', "$1::text[]")} - ORDER BY f."shownSince", f."userId" LIMIT $2`, - [setup.founding, MAX_SHOWN_FOUNDERS], + origin, + (billing) => + Promise.all([ + foundingPurchases(billing, setup.founding), + // Shown only while opted in and the founding subscription still + // holds, so a refund or a lapse takes the founder out of the row. + queryDatabase<{ name: string; image: string | null; token: string }>( + host.databaseUrl, + `SELECT f.name, u.image, encode(sha256(convert_to(f."userId", 'UTF8')), 'hex') AS token + FROM dormouse_founders f JOIN "user" u ON u.id = f."userId" + WHERE ${subscribedSql('f."userId"', "$1::text[]")} + ORDER BY f."shownSince", f."userId" LIMIT $2`, + [setup.founding, MAX_SHOWN_FOUNDERS], + ), + ]), ); + const open = openCohort(gross, net); const avatars: Snapshot["avatars"] = new Map(); const shown = rows.map(({ name, image, token }) => { const source = avatarSource(image); if (!source) return { name }; - avatars.set(token, { source }); + const kept = previous?.avatars.get(token); + avatars.set(token, kept?.loaded && kept.source === source ? { source, loaded: kept.loaded } : { source }); return { name, avatar: FOUNDER_AVATAR_PREFIX + token }; }); const body = { @@ -164,11 +173,13 @@ async function snapshot(host: CohortHost, setup: BillingSetup, origin: string): * The Hosted page's founders row and seat counter, which the site's Worker * forwards from `dormouse.sh/api/hosted/*`: public reads that resolve no * login, each answer reused for {@link COHORT_CACHE_MS} in this isolate. - * Closed, both answer as billing's routes do. Call before any `/api/*` - * catch-all. + * Closed, the cohort read answers 503 `CHECKOUT_CLOSED`, and a picture 404. + * Call before any `/api/*` catch-all. */ export function cohortRoutes(app: Hono, host: (c: Context) => CohortHost, now = () => Date.now()) { let cached: { at: number; value: Promise } | null = null; + /** The last answer built, whose loaded pictures the next one keeps. */ + let last: Snapshot | undefined; /** * Keeps `work`, which later requests await too, running past the end of * the request that started it: a Worker cancels a request's outstanding @@ -185,13 +196,16 @@ export function cohortRoutes(app: Hono, host: (c: Context) => CohortHost, n }; const current = (c: Context, setup: BillingSetup) => { if (!cached || now() - cached.at >= COHORT_CACHE_MS) { - const value = shared(c, snapshot(host(c), setup, new URL(c.req.url).origin)); + const value = shared(c, snapshot(host(c), setup, new URL(c.req.url).origin, last)); const entry = { at: now(), value }; cached = entry; // A failure is never reused: the next request tries again. - value.catch(() => { - if (cached === entry) cached = null; - }); + value.then( + (built) => (last = built), + () => { + if (cached === entry) cached = null; + }, + ); } return cached.value; }; @@ -207,8 +221,18 @@ 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 ??= shared(c, loadAvatar(entry.source).catch(() => null)); - const avatar = await entry.loaded; + const avatar = + entry.loaded ?? + (await (entry.loading ??= shared( + c, + loadAvatar(entry.source).then( + (avatar) => { + if (avatar) entry.loaded = avatar; + return avatar; + }, + () => null, + ), + ))); if (!avatar) return c.json({ message: "Not found." }, 404); return c.body(avatar.bytes, 200, { "content-type": avatar.type }); }); diff --git a/hosted/server/dormouse-migrations/020_withdrawal_notices.sql b/hosted/server/dormouse-migrations/020_withdrawal_notices.sql new file mode 100644 index 000000000..5eb324487 --- /dev/null +++ b/hosted/server/dormouse-migrations/020_withdrawal_notices.sql @@ -0,0 +1,33 @@ +-- Up Migration +-- A withdrawal's emailed acknowledgement is a billing notice like the others +-- (docs/specs/hosted.md -> "Billing notices"): recorded in the statement that +-- records its withdrawal, then leased, sent, and retried with them, one per +-- withdrawal. +ALTER TABLE dormouse_billing_notices ADD COLUMN "withdrawalId" text REFERENCES dormouse_withdrawals (id); +ALTER TABLE dormouse_billing_notices DROP CONSTRAINT dormouse_billing_notices_kind_check; +ALTER TABLE dormouse_billing_notices ADD CONSTRAINT dormouse_billing_notices_kind_check + CHECK (kind IN ('renewal', 'annual', 'price_change', 'dispute_suspended', 'dispute_restored', 'withdrawal')); +ALTER TABLE dormouse_billing_notices ADD CONSTRAINT dormouse_billing_notices_withdrawal_check + CHECK ((kind = 'withdrawal') = ("withdrawalId" IS NOT NULL)); +CREATE UNIQUE INDEX dormouse_billing_notices_withdrawal + ON dormouse_billing_notices ("withdrawalId") WHERE "withdrawalId" IS NOT NULL; + +-- Each recorded withdrawal's acknowledgement, sent when `acknowledgedAt` says; +-- one sent before this migration kept no address, so its email is empty. +INSERT INTO dormouse_billing_notices (id, "userId", kind, "withdrawalId", "createdAt", "sentAt", email) +SELECT gen_random_uuid()::text, "userId", 'withdrawal', id, "submittedAt", "acknowledgedAt", + CASE WHEN "acknowledgedAt" IS NOT NULL THEN '' END +FROM dormouse_withdrawals; +ALTER TABLE dormouse_withdrawals DROP COLUMN "acknowledgedAt"; + +-- Down Migration +ALTER TABLE dormouse_withdrawals ADD COLUMN "acknowledgedAt" timestamptz; +UPDATE dormouse_withdrawals w SET "acknowledgedAt" = n."sentAt" +FROM dormouse_billing_notices n WHERE n."withdrawalId" = w.id; +DELETE FROM dormouse_billing_notices WHERE kind = 'withdrawal'; +DROP INDEX dormouse_billing_notices_withdrawal; +ALTER TABLE dormouse_billing_notices DROP CONSTRAINT dormouse_billing_notices_withdrawal_check; +ALTER TABLE dormouse_billing_notices DROP CONSTRAINT dormouse_billing_notices_kind_check; +ALTER TABLE dormouse_billing_notices ADD CONSTRAINT dormouse_billing_notices_kind_check + CHECK (kind IN ('renewal', 'annual', 'price_change', 'dispute_suspended', 'dispute_restored')); +ALTER TABLE dormouse_billing_notices DROP COLUMN "withdrawalId"; diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts index 6f0871223..7eeb0cdda 100644 --- a/hosted/server/entitlement.ts +++ b/hosted/server/entitlement.ts @@ -12,8 +12,6 @@ if (!/^[^'\\]+$/.test(ADMIN_EMAIL)) throw new Error("ADMIN_EMAIL cannot be inlin * subscription; access needs exactly one of them. */ export const CURRENT_STATUSES: readonly string[] = ["trialing", "active", "past_due", "unpaid", "paused", "incomplete"]; -/** Subscription statuses Stripe has finished with (pgstencil's `missing` included). */ -export const ENDED_STATUSES: readonly string[] = ["canceled", "incomplete_expired", "missing"]; const CURRENT = CURRENT_STATUSES.map((status) => `'${status}'`).join(", "); diff --git a/hosted/server/metrics.ts b/hosted/server/metrics.ts index 094a76e01..a8ccc8a6c 100644 --- a/hosted/server/metrics.ts +++ b/hosted/server/metrics.ts @@ -23,7 +23,7 @@ export async function countMetric(databaseUrl: string, event: MetricEvent, label * {@link countMetric} through `query`, such as an open transaction's, so the * count commits or rolls back with it. */ -export async function countMetricWith( +async function countMetricWith( query: (text: string, values: unknown[]) => Promise, event: MetricEvent, label = "", diff --git a/hosted/server/notices.ts b/hosted/server/notices.ts index 272b73c5a..dceb168d1 100644 --- a/hosted/server/notices.ts +++ b/hosted/server/notices.ts @@ -2,14 +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 { DIFFPLUG_POSTAL_ADDRESS, SUPPORT_EMAIL } from "../../website/src/lib/hosted-withdrawal"; -import { publishedPrice, type BillingSetup, type Plan } from "./billing"; -import { BILLING_RETURN_PATH } from "./policy-constants"; -import { PLAN_NAMES, billingContact } from "./withdrawal"; - -const DAY_MS = 24 * 60 * 60 * 1000; +import { PLAN_NAMES, planInterval } from "../../website/src/lib/hosted-pricing"; +import { DIFFPLUG_POSTAL_ADDRESS, SUPPORT_EMAIL, WITHDRAWAL_STATEMENT } from "../../website/src/lib/hosted-withdrawal"; +import { DAY_MS, run, type PriceCatalog, type PriceChange } from "./billing"; +import { BILLING_RETURN_PATH, isPlan, type Plan } from "./policy-constants"; +import { billingContact, type Contact } from "./withdrawal"; /** * A yearly plan's renewal notice, and a monthly plan's annual reminder, are @@ -34,80 +32,19 @@ export const PRICE_NOTICE_MIN_DAYS = 7; * spare and never announces a change that then cannot apply. */ const PRICE_SEND_BY_DAYS = PRICE_NOTICE_MIN_DAYS + 1; -/** How long the cron retries a dispute notice. */ -const DISPUTE_RETRY_DAYS = 30; +/** How long the cron retries a dispute notice or a withdrawal's acknowledgement. */ +const RETRY_DAYS = 30; /** One send attempt holds its notice this long before another may try it. */ const LEASE_MS = 5 * 60 * 1000; /** How many notices one pass sends, and how many price changes it applies, at most. */ const BATCH = 50; -/** A scheduled list price change, as the operator recorded it in `dormouse_price_changes`. */ -export interface PriceChange { - id: string; - plan: "monthly" | "yearly"; - fromPriceId: string; - toPriceId: string; - fromUsd: number; - toUsd: number; - effectiveAt: Date; -} - -type Db = Pick, "executeQuery">; - -const run = (db: Db, text: string, values: unknown[]) => - db.executeQuery(CompiledQuery.raw(text, values)).then((result) => result.rows); - -/** Every scheduled price change, oldest first. */ -export const priceChanges = (db: Db) => - run( - db, - `SELECT id, plan, "fromPriceId", "toPriceId", "fromUsd", "toUsd", "effectiveAt" - FROM public.dormouse_price_changes ORDER BY "createdAt", id`, - [], - ); - -/** - * The changes billing may act on, and the ones it refuses: a change that - * names a founding Price, whose lock no change may touch, or a Price another - * plan's configuration or change already holds, is refused whole. - */ -export function usableChanges(setup: BillingSetup, changes: readonly PriceChange[]) { - const owner = new Map([ - [setup.monthly, "monthly"], - [setup.yearly, "yearly"], - ...setup.founding.map((price) => [price, "founding"] as [string, string]), - ]); - const usable: PriceChange[] = []; - const refused: PriceChange[] = []; - for (const change of changes) { - const prices = [change.fromPriceId, change.toPriceId]; - if (prices.some((price) => (owner.get(price) ?? change.plan) !== change.plan)) refused.push(change); - else { - for (const price of prices) owner.set(price, change.plan); - usable.push(change); - } - } - return { usable, refused }; -} - -/** What `priceId` charges per interval in whole US dollars: published, or a change's; null when neither says. */ -export function priceUsd(setup: BillingSetup, changes: readonly PriceChange[], priceId: string): number | null { - return ( - publishedPrice(setup, priceId) ?? - changes.find((change) => change.toPriceId === priceId)?.toUsd ?? - changes.find((change) => change.fromPriceId === priceId)?.fromUsd ?? - null - ); -} - -const intervalOf = (plan: Plan) => (plan === "monthly" ? "month" : "year"); - /** 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 [ @@ -129,7 +66,7 @@ async function verifyChange(billing: Billing, change: PriceChange) { } } -type Kind = "renewal" | "annual" | "price_change" | "dispute_suspended" | "dispute_restored"; +type Kind = "renewal" | "annual" | "price_change" | "dispute_suspended" | "dispute_restored" | "withdrawal"; /** A notice to send, with what its text needs. */ interface Pending { @@ -139,30 +76,39 @@ interface Pending { renewsAt: Date | null; priceId: string | null; priceChangeId: string | null; + /** A withdrawal's: its reference, plan, when its contract started, and when it was submitted. */ + withdrawalId: string | null; + withdrawalPlan: string | null; + contractStartedAt: Date | null; + submittedAt: Date | null; } /** 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; } const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }); +const UTC = new Intl.DateTimeFormat("en-GB", { dateStyle: "long", timeStyle: "long", timeZone: "UTC" }); +const UTC_DAY = new Intl.DateTimeFormat("en-GB", { dateStyle: "long", timeZone: "UTC" }); + +/** A paragraph, or a list of terms and their values. */ +type Block = string | readonly (readonly [string, string])[]; /** 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.`; const help = `Questions: ${SUPPORT_EMAIL}.`; let subject: string; - let paragraphs: string[]; + let paragraphs: Block[]; switch (notice.kind) { case "renewal": subject = `Your Dormouse Hosted subscription renews on ${on}`; @@ -182,7 +128,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.`, @@ -207,6 +153,23 @@ export function noticeMessage(to: string, notice: Pending, plan: Plan | null, co `This does not limit any refund or remedy owed to you. ${help}`, ]; break; + case "withdrawal": { + // The durable acknowledgement docs/specs/pricing.md -> "Checkout and entitlement" requires. + const contract = isPlan(notice.withdrawalPlan) ? PLAN_NAMES[notice.withdrawalPlan] : notice.withdrawalPlan; + subject = "We received your withdrawal from Dormouse Hosted"; + paragraphs = [ + "DiffPlug LLC received your withdrawal from your Dormouse Hosted subscription contract.", + [ + ["Your statement", WITHDRAWAL_STATEMENT], + ["Submitted", UTC.format(notice.submittedAt!)], + ["Contract", `Dormouse Hosted, ${contract}, ordered on ${UTC_DAY.format(notice.contractStartedAt!)}`], + ["Reference", notice.withdrawalId!], + ], + "Your subscription ends with this withdrawal and will not renew. We refund every payment you made for it in full, including tax, to your original payment method within 14 days of your withdrawal, with no deduction. Your bank or card provider decides when the credit appears.", + `Keep this email as your record. Questions: ${SUPPORT_EMAIL}.`, + ]; + break; + } } paragraphs.push(DIFFPLUG_POSTAL_ADDRESS); return { @@ -214,8 +177,16 @@ export function noticeMessage(to: string, notice: Pending, plan: Plan | null, co to: [to], replyTo: SUPPORT_EMAIL, subject, - text: paragraphs.join("\n\n"), - html: `${paragraphs.map((paragraph) => `

${escape(paragraph)}

`).join("")}`, + text: paragraphs + .map((block) => (typeof block === "string" ? block : block.map(([term, value]) => `${term}: ${value}`).join("\n"))) + .join("\n\n"), + html: `${paragraphs + .map((block) => + typeof block === "string" + ? `

${escape(block)}

` + : `
${block.map(([term, value]) => `
${escape(term)}
${escape(value)}
`).join("")}
`, + ) + .join("")}`, }; } @@ -225,61 +196,67 @@ export function noticeMessage(to: string, notice: Pending, plan: Plan | null, co * A renewal-type notice still applies while its subscription renews at that * renewal, a price change's only while it is further off than * {@link PRICE_SEND_BY_DAYS} and its change is among `verified`; a suspension - * while its dispute is open; a dispute notice for {@link DISPUTE_RETRY_DAYS}. - * Each is leased before it is sent, so two passes never send one twice. - * 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. + * while its dispute is open; a dispute notice or a withdrawal's + * acknowledgement for {@link RETRY_DAYS}. + * The batch is leased in the statement that picks it, so two passes never + * send one twice. Never-tried notices go first, then the least recently + * tried, so ones that keep failing never starve newer ones past their + * deadlines. Each account's contact is read from Stripe once per pass; + * `contacts` may hold one its caller just read. Answers the failures. */ export async function sendNotices( billing: Billing, mail: () => EmailSender, context: NoticeContext, - { userId = null, verified = [] }: { userId?: string | null; verified?: readonly string[] } = {}, + { + userId = null, + verified = [], + contacts = new Map(), + }: { userId?: string | null; verified?: readonly string[]; contacts?: Map } = {}, ): Promise { const now = billing.time.now(); - const pending = await run( + const leased = await run( billing.db, - `SELECT n.id, n."userId", n.kind, n."renewsAt", s.price_id AS "priceId", n."priceChangeId" - FROM public.dormouse_billing_notices n - LEFT JOIN pgstencil_billing.subscriptions s ON s.id = n."subscriptionId" - LEFT JOIN public.dormouse_price_changes c ON c.id = n."priceChangeId" - WHERE n."sentAt" IS NULL AND (n."attemptedAt" IS NULL OR n."attemptedAt" < $2) - AND ($3::text IS NULL OR n."userId" = $3) - AND CASE - WHEN n.kind IN ('renewal', 'annual') THEN - s.status = 'active' AND NOT s.cancel_at_period_end AND s.period_end = n."renewsAt" AND n."renewsAt" > $1::timestamptz - WHEN n.kind = 'price_change' THEN - s.status = 'active' AND NOT s.cancel_at_period_end AND s.period_end = n."renewsAt" - AND s.price_id = c."fromPriceId" AND c.id = ANY ($6::text[]) - AND n."renewsAt" > $1::timestamptz + make_interval(days => $7) - WHEN n.kind = 'dispute_suspended' THEN - n."createdAt" > $4 - AND EXISTS (SELECT FROM public.dormouse_billing_disputes d WHERE d."disputeId" = n."disputeId") - ELSE n."createdAt" > $4 - END - ORDER BY n."attemptedAt" NULLS FIRST, n."createdAt", n.id LIMIT $5`, + `WITH due AS ( + SELECT n.id, s.price_id AS "priceId", w.plan AS "withdrawalPlan", w."contractStartedAt", w."submittedAt" + FROM public.dormouse_billing_notices n + LEFT JOIN pgstencil_billing.subscriptions s ON s.id = n."subscriptionId" + LEFT JOIN public.dormouse_price_changes c ON c.id = n."priceChangeId" + LEFT JOIN public.dormouse_withdrawals w ON w.id = n."withdrawalId" + WHERE n."sentAt" IS NULL AND (n."attemptedAt" IS NULL OR n."attemptedAt" < $2) + AND ($3::text IS NULL OR n."userId" = $3) + AND CASE + WHEN n.kind IN ('renewal', 'annual') THEN + s.status = 'active' AND NOT s.cancel_at_period_end AND s.period_end = n."renewsAt" AND n."renewsAt" > $1::timestamptz + WHEN n.kind = 'price_change' THEN + s.status = 'active' AND NOT s.cancel_at_period_end AND s.period_end = n."renewsAt" + AND s.price_id = c."fromPriceId" AND c.id = ANY ($6::text[]) + AND n."renewsAt" > $1::timestamptz + make_interval(days => $7) + WHEN n.kind = 'dispute_suspended' THEN + n."createdAt" > $4 + AND EXISTS (SELECT FROM public.dormouse_billing_disputes d WHERE d."disputeId" = n."disputeId") + ELSE n."createdAt" > $4 + END + ORDER BY n."attemptedAt" NULLS FIRST, n."createdAt", n.id LIMIT $5 + FOR UPDATE OF n SKIP LOCKED) + UPDATE public.dormouse_billing_notices n SET "attemptedAt" = $1 FROM due WHERE n.id = due.id + RETURNING n.id, n."userId", n.kind, n."renewsAt", due."priceId", n."priceChangeId", + n."withdrawalId", due."withdrawalPlan", due."contractStartedAt", due."submittedAt"`, [ now, new Date(now.getTime() - LEASE_MS), userId, - new Date(now.getTime() - DISPUTE_RETRY_DAYS * DAY_MS), + new Date(now.getTime() - RETRY_DAYS * DAY_MS), BATCH, verified, PRICE_SEND_BY_DAYS, ], ); const failed: unknown[] = []; - for (const notice of pending) + for (const notice of leased) try { - const [leased] = await run( - billing.db, - `UPDATE public.dormouse_billing_notices SET "attemptedAt" = $2 - WHERE id = $1 AND "sentAt" IS NULL AND ("attemptedAt" IS NULL OR "attemptedAt" < $3) RETURNING id`, - [notice.id, billing.time.now(), new Date(billing.time.now().getTime() - LEASE_MS)], - ); - if (!leased) continue; - const email = (await billingContact(billing, notice.userId))?.email; + if (!contacts.has(notice.userId)) contacts.set(notice.userId, await billingContact(billing, notice.userId)); + const email = contacts.get(notice.userId)?.email; if (!email) throw new Error(`Notice ${notice.id}: Stripe gave no billing email to send it to`); await mail().send(noticeMessage(email, notice, billing.planOf(notice.priceId), context)); await run(billing.db, `UPDATE public.dormouse_billing_notices SET "sentAt" = $2, email = $3 WHERE id = $1`, [ @@ -357,13 +334,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 +355,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 +369,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 +385,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..3d44a8c19 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -53,6 +53,12 @@ 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]; + +// A purchase that ends within this many days of starting is a refund: its +// seat returns to its cohort. The 30-day guarantee the page states runs as long. +export const REFUND_DAYS = 30; +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/relay-account.ts b/hosted/server/relay-account.ts index 8322af13d..68db653a5 100644 --- a/hosted/server/relay-account.ts +++ b/hosted/server/relay-account.ts @@ -1,6 +1,5 @@ // Rules: docs/specs/hosted.md -> "Burrow enrollment" and "Metrics". import type { Context, Hono } from "hono"; -import { bodyLimit } from "hono/body-limit"; import { NOT_ENTITLED_ERROR, isE2eId, @@ -8,9 +7,9 @@ import { normalizeEnrollUserCode, readJson, } from "remote-lib-common"; -import { accountQuery, cookieEntitled, type AccountHost } from "./account-gate"; +import { accountQuery, cookieEntitled, isRecentLogin, smallBody, type AccountHost } from "./account-gate"; import { recordMetric } from "./metrics"; -import { ENROLLMENT_TTL_MS, LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW } from "./policy-constants"; +import { ENROLLMENT_TTL_MS, RECENT_LOGIN_WINDOW } from "./policy-constants"; /** What one request's account deployment provides to the Relay's account routes. */ export type RelayAccountHost = AccountHost & { @@ -34,18 +33,11 @@ export const ALREADY_APPROVED = "That code is already approved."; export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAccountHost) { const gate = cookieEntitled(host, (c) => c.json({ message: NOT_ENTITLED_ERROR }, 403)); - const small = bodyLimit({ - maxSize: 1024, - onError: (c) => c.json({ message: "Request too large." }, 413), - }); + const small = smallBody(1024); app.post("/api/relay/enrollments/approve", small, gate, async (c) => { const login = c.get("login"); - // Fails closed: a login whose creation time is missing or unreadable is - // not recent. - const createdAt = Date.parse(String(login.createdAt)); - if (!(Date.now() - createdAt < LOGIN_FRESH_AGE_MS)) - return c.json({ message: RECENT_LOGIN_REQUIRED }, 403); + if (!isRecentLogin(login)) return c.json({ message: RECENT_LOGIN_REQUIRED }, 403); // Every attempt counts, so a signed-in account cannot walk the code space; // as the approvals' only writer, this limit is also what bounds them. if (!(await host(c).approveLimit.limit({ key: login.userId })).success) { diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts index d51635154..5313bf979 100644 --- a/hosted/server/tests/billing-routes.test.ts +++ b/hosted/server/tests/billing-routes.test.ts @@ -7,14 +7,12 @@ import { queryDatabase } from "pgstencil/postgres"; import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER, LIST_ANNUAL, LIST_MONTHLY } from "../../../website/src/lib/hosted-pricing"; import { WITHDRAWAL_STATEMENT } from "../../../website/src/lib/hosted-withdrawal"; import { resyncBilling, type BillingSetup } from "../billing"; -import { MAX_SHOWN_FOUNDERS } from "../../../website/src/lib/hosted-cohorts"; +import { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } from "../../../website/src/lib/hosted-cohorts"; import { BILLING_WEBHOOK_PATH, - CHECKOUT_CLOSED, CONSENT_REQUIRED, ENDED_FOR_REFUND, FOUNDERS_ONLY, - PRICE_CHANGED, TERMS_CHANGED, WEBHOOK_BODY_BYTES, WITHDRAWAL_CLOSED, @@ -22,12 +20,12 @@ import { disputeAction, } from "../billing-routes"; import { CLOSE_RECENT_LOGIN, CLOSE_UNAVAILABLE, ENDED_FOR_CLOSURE, closureRoutes } from "../closure"; -import { AVATAR_BYTES, COHORT_CACHE_MS, COHORT_ENDPOINT, FOUNDER_AVATAR_PREFIX, cohortRoutes } from "../cohort-routes"; +import { AVATAR_BYTES, COHORT_CACHE_MS, FOUNDER_AVATAR_PREFIX, cohortRoutes } from "../cohort-routes"; import { entitledSql } from "../entitlement"; import { ENDED_FOR_WITHDRAWAL, WITHDRAWAL_OPEN_DAYS } from "../withdrawal"; import { migrations } from "../migrations"; import { DEV_FOUNDING_PRICES } from "../billing-dev"; -import { TERMS_VERSION } from "../policy-constants"; +import { CHECKOUT_CLOSED, PRICE_CHANGED, TERMS_VERSION } from "../policy-constants"; import { ORIGINS } from "./bundle"; import { CHANGED_PRICES, CHANGED_USD, stripeFake } from "./stripe-fake"; import { workerDatabases } from "./worker-roles"; @@ -210,23 +208,27 @@ 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 but leaving the founders row answers 503 to a signed-in account, after the login gate", async ({ +test("closed, a signed-in account reads billing as closed and every billing change but leaving the founders row answers 409, after the login gate", async ({ onTestFinished, }) => { const f = await fixture({ closed: true }); onTestFinished(f.close); const member = await f.account(); + const read = await member.request("/api/billing"); + expect([read.status, await read.json()]).toEqual([200, { closed: true }]); + expect((await f.anonymous("/api/billing")).status).toBe(401); for (const [method, path, body] of [ - ["GET", "/api/billing"], ["POST", "/api/billing/checkout", { plan: "monthly" }], ["POST", "/api/billing/confirm", { checkout: "op_x" }], ["POST", "/api/billing/portal"], + ["POST", "/api/billing/withdraw", { subscription: "sub_x" }], ["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 }]); + expect([path, response.status, await response.json()]).toEqual([path, 409, { message: CHECKOUT_CLOSED }]); expect((await f.anonymous(path, method)).status, path).toBe(401); } + // Stripe's events wait, retried, until billing opens. 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. @@ -773,8 +775,11 @@ test("an EEA or UK consumer withdraws online: recorded, acknowledged by email, r expect([other.status, await other.json()]).toEqual([409, { message: WITHDRAWAL_CLOSED }]); expect(await f.sql(`SELECT id FROM dormouse_withdrawals`)).toEqual([]); + // Stripe's customer is read once: for the offer, and then its acknowledgement. + const reads = f.reads.customers; const answered = await withdraw(subscription.id); expect(answered.status).toBe(200); + expect(f.reads.customers - reads).toBe(1); const [row] = await f.sql<{ id: string; userId: string; plan: string; country: string; submittedAt: Date }>( `SELECT id, "userId", plan, country, "submittedAt" FROM dormouse_withdrawals WHERE "subscriptionId" = $1`, [subscription.id], @@ -861,6 +866,8 @@ test("a withdrawal whose acknowledgement, cancellation, or refund fails still st }); expect([f.email.all().length, f.cancels.length, f.refunds.length]).toEqual([0, 0, 0]); + // The failed send's lease runs out before the cron's next pass. + f.time.advanceMilliseconds(6 * 60 * 1000); f.failing.refunds = 1; await expect(resyncBilling(f.setup, f.database.url, origin, f.mail)).rejects.toThrow(AggregateError); expect(await member.summary()).toMatchObject({ plan: null, withdrawn: { acknowledged: true, settled: false } }); @@ -1018,7 +1025,7 @@ test("with billing closed, closing deletes an account without a subscription and [held.id], ); const refused = await held.request("/api/account/close", { method: "POST", body: { close: true } }); - expect([refused.status, await refused.json()]).toEqual([503, { message: CLOSE_UNAVAILABLE }]); + expect([refused.status, await refused.json()]).toEqual([409, { message: CLOSE_UNAVAILABLE }]); expect(await f.sql(`SELECT id FROM "user" WHERE id = $1`, [held.id])).toEqual([{ id: held.id }]); }); @@ -1212,6 +1219,42 @@ test("a dispute's suspension and restoration are each emailed once to the billin expect(await unsent()).toEqual([{ kind: "dispute_suspended" }]); }); +test("one pass reads an account's billing email from Stripe once, however many of its notices it sends", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.account(); + await f.buy(member, "monthly"); + await f.sql( + `INSERT INTO dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") + VALUES ('first', $1, 'dispute_restored', 'du_first', $2), ('second', $1, 'dispute_restored', 'du_second', $2)`, + [member.id, f.time.now()], + ); + const reads = f.reads.customers; + await resyncBilling(f.setup, f.database.url, origin, f.mail); + expect([f.email.all().length, f.reads.customers - reads]).toEqual([2, 1]); +}); + +test("a notice one pass has leased is never sent by another meanwhile", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.account(); + await f.buy(member, "monthly"); + await f.sql( + `INSERT INTO dormouse_billing_notices (id, "userId", kind, "disputeId", "createdAt") VALUES ('once', $1, 'dispute_restored', 'du_once', $2)`, + [member.id, f.time.now()], + ); + // The first send runs a whole second pass before it goes out. + let inner: Promise | null = null; + const mail = () => ({ + send: async (message: Parameters["send"]>[0]) => { + await (inner ??= resyncBilling(f.setup, f.database.url, origin, mail)); + await f.mail().send(message); + }, + }); + await resyncBilling(f.setup, f.database.url, origin, mail); + expect(f.email.all().length).toBe(1); +}); + 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); @@ -1394,7 +1437,7 @@ test("a shown founder's picture is fetched from the provider's host and served a provider.set("https://avatars.githubusercontent.com/u/3?s=96", () => new Response(new TextEncoder().encode(""))); provider.set("https://avatars.githubusercontent.com/u/4?s=96", () => new Response(null, { status: 302, headers: { location: "https://evil.example/" } })); provider.set("https://avatars.githubusercontent.com/u/6?s=96", () => new Response(PNG, { status: 500 })); - await founder("Png", gh); + const pngFounder = await founder("Png", gh); await founder("Big", "https://avatars.githubusercontent.com/u/2"); await founder("Svg", "https://avatars.githubusercontent.com/u/3"); await founder("Moved", "https://avatars.githubusercontent.com/u/4"); @@ -1435,12 +1478,27 @@ test("a shown founder's picture is fetched from the provider's host and served a expect(fetched.filter((url) => url.includes("/u/1")).length).toBe(1); expect(fetched.every((url) => url.startsWith("https://avatars.githubusercontent.com/"))).toBe(true); + // A later answer keeps a picture loaded from the same source, and tries a failed or a changed one again. + const fetches = (path: string) => fetched.filter((url) => url.includes(path)).length; + provider.set("https://avatars.githubusercontent.com/u/7?s=96", () => new Response(JPEG)); + await f.sql(`UPDATE "user" SET image = 'https://avatars.githubusercontent.com/u/7' WHERE id = $1`, [pngFounder.id]); + f.clock.now += COHORT_CACHE_MS; + await f.visit(COHORT_ENDPOINT); + expect((await f.visit(avatar.Png!)).headers.get("content-type")).toBe("image/jpeg"); + expect((await f.visit(avatar.Failed!)).status).toBe(404); + expect((await f.visit(avatar.Inline!)).status).toBe(200); + expect([fetches("/u/1"), fetches("/u/7"), fetches("/u/6")]).toEqual([1, 1, 2]); + f.clock.now += COHORT_CACHE_MS; + await f.visit(COHORT_ENDPOINT); + expect((await f.visit(avatar.Png!)).status).toBe(200); + expect(fetches("/u/7")).toBe(1); + // Only a shown founder's token: a founder who leaves, a made-up token, or a user id is not one. for (const path of [FOUNDER_AVATAR_PREFIX + "0".repeat(64), FOUNDER_AVATAR_PREFIX + "x", FOUNDER_AVATAR_PREFIX]) expect((await f.visit(path)).status, path).toBe(404); }); -test("closed, the public routes answer as billing's do", async ({ onTestFinished }) => { +test("closed, the public cohort read answers 503 and no picture is served", async ({ onTestFinished }) => { const f = await fixture({ closed: true }); onTestFinished(f.close); const response = await f.visit(COHORT_ENDPOINT); 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/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 73b15e464..90c4848f3 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -33,8 +33,10 @@ import { cookieEntitled, openAuthRead } from "../account-gate"; import { createAuthApp } from "@pgstencil/auth/better-auth"; import { authPolicy } from "../policy"; import { prelaunchAuth } from "../prelaunch"; -import { BILLING_WEBHOOK_PATH, CHECKOUT_CLOSED } from "../billing-routes"; -import { COHORT_ENDPOINT, FOUNDER_AVATAR_PREFIX } from "../cohort-routes"; +import { BILLING_WEBHOOK_PATH } from "../billing-routes"; +import { FOUNDER_AVATAR_PREFIX } from "../cohort-routes"; +import { COHORT_ENDPOINT } from "../../../website/src/lib/hosted-cohorts"; +import { CHECKOUT_CLOSED } from "../policy-constants"; import { RECENT_LOGIN_REQUIRED, relayAccountRoutes } from "../relay-account"; import type { RelayRoomRpc } from "../relay-room-contract"; import { voiceApp } from "../voice-app"; diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index d728f6cc8..c66081c79 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -51,6 +51,8 @@ const PINNED: Record = { "20fe04c88694fd1cd00a83746ec8486e9bea7b0cb35aaabfd9c5a3fc4d6555c0", "019_billing_records_outlive_closure.sql": "0f8569957ef76ba35ce81460d68fdae915674dced385058b490cd099d03b8242", + "020_withdrawal_notices.sql": + "9a040646fb1d16942d8a6100c2509363867ad2381e00dc094a96dca6aea2f7b8", }; const directory = new URL("../dormouse-migrations/", import.meta.url); @@ -93,3 +95,39 @@ test("010 deletes the sessions that name no passkey, and every later one must na expect(await sql(`SELECT "tokenHash" FROM dormouse_relay_sessions`)).toEqual([{ tokenHash: "named" }]); await expect(session("later", null)).rejects.toThrow(/credentialId/); }); + +test("020 records each withdrawal's acknowledgement as its withdrawal notice, sent when it was, one per withdrawal", async ({ + onTestFinished, +}) => { + // The Dormouse migrations through 019, then 020 over the withdrawals they recorded. + const before = mkdtempSync(join(tmpdir(), "dormouse-migrations-")); + onTestFinished(() => rmSync(before, { recursive: true, force: true })); + const source = fileURLToPath(directory); + for (const name of Object.keys(PINNED).filter((name) => name < "020")) copyFileSync(join(source, name), join(before, name)); + const context = await createTestContext({ migrations: [betterAuthMigrations, before] }); + onTestFinished(context.close); + const sql = (text: string, values?: unknown[]) => queryDatabase(context.database.url, text, values); + const submittedAt = new Date("2026-11-05T12:00:00Z"); + const acknowledgedAt = new Date("2026-11-05T12:00:05Z"); + for (const [id, acknowledged] of [["acknowledged", acknowledgedAt], ["unacknowledged", null]] as const) + await sql( + `INSERT INTO dormouse_withdrawals (id, "userId", "subscriptionId", plan, "priceId", "contractStartedAt", "submittedAt", "acknowledgedAt") + VALUES ($1, 'u', $1, 'yearly', 'price_y', $2, $2, $3)`, + [id, submittedAt, acknowledged], + ); + + copyFileSync(join(source, "020_withdrawal_notices.sql"), join(before, "020_withdrawal_notices.sql")); + await migrate(context.database.url, await readMigrations([betterAuthMigrations, before])); + expect( + await sql(`SELECT "withdrawalId", "userId", kind, "createdAt", "sentAt", email FROM dormouse_billing_notices ORDER BY "withdrawalId"`), + ).toEqual([ + { withdrawalId: "acknowledged", userId: "u", kind: "withdrawal", createdAt: submittedAt, sentAt: acknowledgedAt, email: "" }, + { withdrawalId: "unacknowledged", userId: "u", kind: "withdrawal", createdAt: submittedAt, sentAt: null, email: null }, + ]); + expect(await sql(`SELECT column_name FROM information_schema.columns WHERE column_name = 'acknowledgedAt'`)).toEqual([]); + await expect( + sql( + `INSERT INTO dormouse_billing_notices (id, "userId", kind, "withdrawalId", "createdAt") VALUES ('again', 'u', 'withdrawal', 'acknowledged', now())`, + ), + ).rejects.toThrow(/dormouse_billing_notices_withdrawal/); +}); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index a1c69a0d8..eeef6a5af 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -25,12 +25,11 @@ import { CRON_SWEEP_CAP, SPEECH_SWEEP_CAP, TOKEN_SWEEP_CRON, - VOICE_DAILY_CAP, VOICE_TOKEN_IDLE_DAYS, } from "../voice"; import { mintVoiceToken } from "../voice-token"; import { ALREADY_APPROVED, RECENT_LOGIN_REQUIRED } from "../relay-account"; -import { TERMS_VERSION } from "../policy-constants"; +import { TERMS_VERSION, VOICE_DAILY_CAP } from "../policy-constants"; import { API_ROUTES, NOT_ENTITLED_ERROR, diff --git a/hosted/server/voice.ts b/hosted/server/voice.ts index e7d54e949..000902a58 100644 --- a/hosted/server/voice.ts +++ b/hosted/server/voice.ts @@ -8,7 +8,6 @@ import { entitledSql } from "./entitlement"; import { recordMetric } from "./metrics"; import { VOICE_DAILY_CAP } from "./policy-constants"; -export { VOICE_DAILY_CAP }; /** A token unused this long is refused, as if it never existed. */ export const VOICE_TOKEN_IDLE_DAYS = 365; /** Whether the token aliased `t` is live: used, or minted, within the idle bound. */ diff --git a/hosted/server/withdrawal.ts b/hosted/server/withdrawal.ts index d37b760f0..c4001d78c 100644 --- a/hosted/server/withdrawal.ts +++ b/hosted/server/withdrawal.ts @@ -1,15 +1,10 @@ // Rules: docs/specs/hosted.md -> "Billing"; docs/specs/pricing.md -> // "Checkout and entitlement". 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 { DIFFPLUG_POSTAL_ADDRESS, SUPPORT_EMAIL, WITHDRAWAL_STATEMENT } from "../../website/src/lib/hosted-withdrawal"; -import type { Plan } from "./billing"; -import { CURRENT_STATUSES, ENDED_STATUSES } from "./entitlement"; - -const DAY_MS = 24 * 60 * 60 * 1000; +import { DAY_MS, idOf, run } from "./billing"; +import type { WithdrawalOffer } from "./billing-summary"; +import type { Plan } from "./policy-constants"; +import { CURRENT_STATUSES } from "./entitlement"; /** * How long after a subscription starts the online withdrawal stays open. The @@ -26,7 +21,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"], @@ -37,28 +32,13 @@ export const WITHDRAWAL_COUNTRIES: ReadonlySet = new Set([ /** The cancellation comment a subscription ended by withdrawal carries. */ export const ENDED_FOR_WITHDRAWAL = "dormouse: EEA or UK withdrawal"; -/** How long the cron keeps retrying a withdrawal's acknowledgement or settlement. */ +/** How long the cron keeps retrying a withdrawal's 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 { @@ -68,14 +48,14 @@ export interface WithdrawalRow { plan: string; contractStartedAt: Date; submittedAt: Date; - acknowledgedAt: Date | null; settledAt: Date | null; } /** 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; +/** A Stripe customer's billing email and address country; null when Stripe cannot say. */ +export type Contact = { email: string | null; country: string | null } | null; const contacts = new WeakMap>(); /** @@ -121,7 +101,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); @@ -137,15 +117,12 @@ export async function withdrawalOffer( }; } -const run = (billing: Billing, text: string, values: unknown[]) => - billing.db.executeQuery(CompiledQuery.raw(text, values)).then((result) => result.rows); - -const COLUMNS = `id, "userId", "subscriptionId", plan, "contractStartedAt", "submittedAt", "acknowledgedAt", "settledAt"`; +const COLUMNS = `id, "userId", "subscriptionId", plan, "contractStartedAt", "submittedAt", "settledAt"`; /** The withdrawal recorded from `subscriptionId` by `ownerId`, if any. */ export async function recordedWithdrawal(billing: Billing, ownerId: string, subscriptionId: string) { const [row] = await run( - billing, + billing.db, `SELECT ${COLUMNS} FROM public.dormouse_withdrawals WHERE "subscriptionId" = $1 AND "userId" = $2`, [subscriptionId, ownerId], ); @@ -154,21 +131,28 @@ export async function recordedWithdrawal(billing: Billing, ownerId: string /** * Records `ownerId`'s withdrawal from `subscription` under `offer`, submitted - * now, and answers the row: a second submission answers the first's. + * now, and in the same statement its acknowledgement, a `withdrawal` notice + * the notice pass sends; answers the row, and a second submission the first's. */ export async function recordWithdrawal( billing: Billing, ownerId: string, plan: Plan, subscription: Held, - offer: WithdrawalOffer, + offer: Offer, ): Promise { - await run( - billing, - `INSERT INTO public.dormouse_withdrawals - (id, "userId", "subscriptionId", plan, "priceId", "contractStartedAt", country, "submittedAt") - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT ("subscriptionId") DO NOTHING`, + const [recorded] = await run( + billing.db, + `WITH recorded AS ( + INSERT INTO public.dormouse_withdrawals + (id, "userId", "subscriptionId", plan, "priceId", "contractStartedAt", country, "submittedAt") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT ("subscriptionId") DO NOTHING + RETURNING ${COLUMNS}), + acknowledgement AS ( + INSERT INTO public.dormouse_billing_notices (id, "userId", kind, "withdrawalId", "createdAt") + SELECT gen_random_uuid()::text, "userId", 'withdrawal', id, "submittedAt" FROM recorded) + SELECT * FROM recorded`, [ crypto.randomUUID(), ownerId, @@ -180,37 +164,7 @@ export async function recordWithdrawal( billing.time.now(), ], ); - return (await recordedWithdrawal(billing, ownerId, subscription.id))!; -} - -const UTC = new Intl.DateTimeFormat("en-GB", { dateStyle: "long", timeStyle: "long", timeZone: "UTC" }); -const UTC_DAY = new Intl.DateTimeFormat("en-GB", { dateStyle: "long", timeZone: "UTC" }); - -/** The durable acknowledgement of withdrawal `row`, emailed to `to`. */ -export function withdrawalAcknowledgement(to: string, row: WithdrawalRow): EmailMessage { - const plan = PLAN_NAMES[row.plan as Plan] ?? row.plan; - const facts: [string, string][] = [ - ["Your statement", WITHDRAWAL_STATEMENT], - ["Submitted", UTC.format(row.submittedAt)], - ["Contract", `Dormouse Hosted, ${plan}, ordered on ${UTC_DAY.format(row.contractStartedAt)}`], - ["Reference", row.id], - ]; - const refund = - "Your subscription ends with this withdrawal and will not renew. We refund every payment you made for it in full, including tax, to your original payment method within 14 days of your withdrawal, with no deduction. Your bank or card provider decides when the credit appears."; - const keep = `Keep this email as your record. Questions: ${SUPPORT_EMAIL}.`; - const intro = "DiffPlug LLC received your withdrawal from your Dormouse Hosted subscription contract."; - return { - from: `Dormouse Hosted <${SUPPORT_EMAIL}>`, - to: [to], - replyTo: SUPPORT_EMAIL, - subject: "We received your withdrawal from Dormouse Hosted", - text: [intro, facts.map(([term, value]) => `${term}: ${value}`).join("\n"), refund, keep, DIFFPLUG_POSTAL_ADDRESS].join( - "\n\n", - ), - html: `

${escape(intro)}

${facts - .map(([term, value]) => `
${escape(term)}
${escape(value)}
`) - .join("")}

${escape(refund)}

${escape(keep)}

${escape(DIFFPLUG_POSTAL_ADDRESS)}

`, - }; + return recorded ?? (await recordedWithdrawal(billing, ownerId, subscription.id))!; } /** What a refund is for, named in its metadata and its idempotency key: a withdrawal by its id, a closure by its subscription's. */ @@ -250,7 +204,7 @@ export async function refundPaidInvoices( 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; + const intent = idOf(payment.payment_intent); 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`); } @@ -269,7 +223,7 @@ async function settle(billing: Billing, row: WithdrawalRow) { .select("status") .where("id", "=", row.subscriptionId) .executeTakeFirst(); - if (synced && !ENDED_STATUSES.includes(synced.status)) { + if (synced && CURRENT_STATUSES.includes(synced.status)) { await billing.stripe.subscriptions.cancel(row.subscriptionId, { cancellation_details: { comment: ENDED_FOR_WITHDRAWAL }, prorate: false, @@ -281,50 +235,32 @@ async function settle(billing: Billing, row: WithdrawalRow) { } /** - * Finishes withdrawal `row`: emails its acknowledgement to the billing email, - * then settles it, each step skipped once done and stamped once it is. Throws - * after both were tried if either failed; the cron tries again. + * Settles withdrawal `row` unless it was ({@link settle}), and stamps it + * settled. Throws on a failure; the cron tries again. */ -export async function completeWithdrawal(billing: Billing, mail: () => EmailSender, row: WithdrawalRow) { - const failed: unknown[] = []; - const stamp = (column: "acknowledgedAt" | "settledAt") => - run(billing, `UPDATE public.dormouse_withdrawals SET "${column}" = $2 WHERE id = $1`, [row.id, billing.time.now()]); - if (!row.acknowledgedAt) - try { - const email = (await billingContact(billing, row.userId))?.email; - if (!email) throw new Error(`Withdrawal ${row.id}: Stripe gave no billing email to acknowledge to`); - await mail().send(withdrawalAcknowledgement(email, row)); - await stamp("acknowledgedAt"); - } catch (error) { - failed.push(error); - } - if (!row.settledAt) - try { - await settle(billing, row); - await stamp("settledAt"); - } catch (error) { - failed.push(error); - } - if (failed.length) throw new AggregateError(failed, `Withdrawal ${row.id} is not complete`); +export async function settleWithdrawal(billing: Billing, row: WithdrawalRow) { + if (row.settledAt) return; + await settle(billing, row); + await run(billing.db, `UPDATE public.dormouse_withdrawals SET "settledAt" = $2 WHERE id = $1`, [row.id, billing.time.now()]); } -/** How many withdrawals one cron run finishes at most. */ +/** How many withdrawals one cron run settles at most. */ const RETRY_BATCH = 20; /** - * The cron's pass: finishes every withdrawal submitted in the last - * {@link RETRY_DAYS} days whose acknowledgement or settlement failed, up to - * {@link RETRY_BATCH}; answers the failures. + * The cron's pass: settles every withdrawal submitted in the last + * {@link RETRY_DAYS} days whose settlement failed, up to {@link RETRY_BATCH}; + * answers the failures. Its acknowledgement is the notice pass's. */ -export async function retryWithdrawals(billing: Billing, mail: () => EmailSender): Promise { +export async function settleWithdrawals(billing: Billing): Promise { const rows = await run( - billing, + billing.db, `SELECT ${COLUMNS} FROM public.dormouse_withdrawals - WHERE ("acknowledgedAt" IS NULL OR "settledAt" IS NULL) AND "submittedAt" > $1 + WHERE "settledAt" IS NULL AND "submittedAt" > $1 ORDER BY "submittedAt" LIMIT $2`, [new Date(billing.time.now().getTime() - RETRY_DAYS * DAY_MS), RETRY_BATCH], ); const failed: unknown[] = []; - for (const row of rows) await completeWithdrawal(billing, mail, row).catch((error: unknown) => failed.push(error)); + for (const row of rows) await settleWithdrawal(billing, row).catch((error: unknown) => failed.push(error)); return failed; } diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index 7874b5ffa..a4c13f844 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -6,8 +6,9 @@ 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 { ENROLLMENT_POLL_INTERVAL_S, PRICE_CHANGED, TERMS_VERSION } from "../server/policy-constants"; +import type { BillingSummary } from "../server/billing-summary"; +import type { CheckoutIntent } from "./billing"; +import { CHECKOUT_CLOSED, 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"; @@ -413,6 +414,19 @@ describe("checkout", () => { }); describe("billing", () => { + it("reads the account again on coming back, once for both of its events", async () => { + vi.mocked(api.getSession).mockResolvedValue(login()); + await mount(null); + expect(api.getBilling).toHaveBeenCalledTimes(1); + await act(async () => { + window.dispatchEvent(new Event("focus")); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new Event("focus")); + }); + expect(api.getComputers).toHaveBeenCalledTimes(2); + expect(api.getBilling).toHaveBeenCalledTimes(2); + }); + it("confirms the checkout Stripe returned from once, then keeps its confirmation", async () => { history.replaceState(null, "", "/billing"); vi.mocked(api.getSession).mockResolvedValue(login()); @@ -562,6 +576,21 @@ describe("withdrawal and closure", () => { }); }); +describe("billing reads", () => { + afterEach(() => vi.restoreAllMocks()); + + it("reads closed billing from a 200's body, and any 5xx as an outage", async () => { + const { getBilling, closeAccount } = await vi.importActual("./api"); + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ closed: true })); + expect(await getBilling()).toBe("closed"); + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ message: CHECKOUT_CLOSED }, { status: 503 })); + await expect(getBilling()).rejects.toThrow("Billing is temporarily unavailable"); + // A closure billing refuses shows the server's message. + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ message: "Email support." }, { status: 409 })); + await expect(closeAccount()).rejects.toThrow("Email support."); + }); +}); + describe("startCheckout", () => { afterEach(() => vi.restoreAllMocks()); diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index c5b2c860c..33e8b10a4 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, @@ -252,12 +254,16 @@ export function App({ read: false, }); }); + // Returning to the tab fires both events below on most browsers: one + // refresh answers both. + let returning: Promise | null = null; const focus = () => { - if (!actionPending.current) - void refresh().catch((error) => setError(pageError(error))); + if (actionPending.current || returning) return; + returning = refresh() + .catch((error) => setError(pageError(error))) + .finally(() => (returning = null)); }; - // Returning to the tab: mobile Safari fires no focus coming back from - // another app. + // Mobile Safari fires no focus coming back from another app. const visible = () => { if (document.visibilityState === "visible") focus(); }; @@ -377,16 +383,20 @@ export function App({ setEnterCode(false); }); } + /** Drops what the page shows of the account, once it is signed out or closed. */ + const forgetAccount = () => { + setSession(null); + setAccounts([]); + setHostedVoice(null); + setComputers(null); + setPasskeys(null); + setBilling(null); + setRefusedPlan(false); + }; const signOut = () => mutate("logout", async () => { await post("sign-out"); - setSession(null); - setAccounts([]); - setHostedVoice(null); - setComputers(null); - setPasskeys(null); - setBilling(null); - setRefusedPlan(false); + forgetAccount(); setEnterCode(false); setCode(""); setNotice({ @@ -478,13 +488,7 @@ export function App({ const closeAccount = () => mutate("close", async () => { await closeAccountRequest(); - setSession(null); - setAccounts([]); - setHostedVoice(null); - setComputers(null); - setPasskeys(null); - setBilling(null); - setRefusedPlan(false); + forgetAccount(); setNotice({ text: "Your account is closed. We keep billing records as the privacy policy describes." }); }); const leaveBilling = () => { diff --git a/hosted/src/Checkout.tsx b/hosted/src/Checkout.tsx index ce3a274b6..82486eec8 100644 --- a/hosted/src/Checkout.tsx +++ b/hosted/src/Checkout.tsx @@ -6,13 +6,14 @@ 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 { HELP_CLASS, PRIMARY_CLASS } from "./classes"; +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, refundWindow } from "./billing"; +import { CHECK_CLASS, HELP_CLASS, PRIMARY_CLASS } from "./classes"; import { WithdrawalRight } from "./Withdrawal"; const TERMS_URL = "https://dormouse.sh/terms/"; -const CHECK_CLASS = "mt-1 size-4 shrink-0 accent-[var(--docs-accent)]"; /** `$50/year`. */ export const perInterval = (price: number, interval: "month" | "year") => `$${price}/${interval}`; @@ -22,19 +23,20 @@ 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 (
    {[ - `$${offer.price} a ${offer.interval}, plus any applicable tax. Stripe shows the total before you pay.`, + `$${offer.price} a ${interval}, plus any applicable tax. Stripe shows the total before you pay.`, `Renews automatically ${every} at $${offer.price} until you cancel.${ - offer.plan === "founding" ? " Your founding price stays for as long as your subscription does." : "" + offer.id === "founding" ? " Your founding price stays for as long as your subscription does." : "" }`, "Cancel any time from your account page. You keep Hosted until the end of the period you paid for.", - offer.interval === "year" - ? "Ask within 30 days of your first payment or of a yearly renewal for a full refund, which ends the plan." - : "Ask within 30 days of your first payment for a full refund, which ends the plan. Monthly renewals are outside this guarantee.", + `Ask within ${refundWindow(offer.id)} for a full refund, which ends the plan.${ + offer.id === "monthly" ? " Monthly renewals are outside this guarantee." : "" + }`, `For one person, including work use, with no device limit. Natural Voice speaks up to ${VOICE_DAILY_CAP} alarms a day; past that, alarms use the system voice.`, ].map((line) => (
  • @@ -64,11 +66,11 @@ export function Checkout({ }) { const offers = planOffers(billing.founding); const [chosen, setChosen] = useState( - offers.find((offer) => offer.plan === plan)?.plan ?? offers[0]!.plan, + offers.find((offer) => offer.id === plan)?.id ?? offers[0]!.id, ); const [terms, setTerms] = useState(false); const [renewal, setRenewal] = useState(false); - const offer = offers.find((each) => each.plan === chosen)!; + const offer = offers.find((each) => each.id === chosen)!; function submit(event: FormEvent) { event.preventDefault(); if (terms && renewal && !busy) onPurchase(chosen, offer.price); @@ -79,19 +81,19 @@ export function Checkout({ Choose a plan {offers.map((each) => ( diff --git a/hosted/src/Closure.tsx b/hosted/src/Closure.tsx index a8fecbd21..fa5460bec 100644 --- a/hosted/src/Closure.tsx +++ b/hosted/src/Closure.tsx @@ -1,12 +1,12 @@ import { useRef, type ReactNode } from "react"; import { MUTED_TEXT_CLASS, PANEL_CLASS, siteButton } from "dormouse-lib/site/docs-tokens"; import { RECENT_LOGIN_WINDOW } from "../server/policy-constants"; -import { planName, type BillingSummary } from "./billing"; +import { PLAN_NAMES } from "../../website/src/lib/hosted-pricing.ts"; +import type { BillingSummary } from "../server/billing-summary"; +import { day } from "./billing"; import { BUTTON_CLASS, HEADING_CLASS, HELP_CLASS } from "./classes"; import { SupportLink } from "./Withdrawal"; -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 @@ -17,7 +17,7 @@ const day = (iso: string) => new Date(iso).toLocaleDateString(); export function closureTerms(billing: BillingSummary | "closed" | null): ReactNode[] | null { if (billing === null) return null; if (billing === "closed" || !billing.plan) return ["You have no plan, so nothing is charged or refunded."]; - const name = planName(billing.plan); + const name = PLAN_NAMES[billing.plan]; return [ billing.until && billing.active ? `Your ${name} plan ends now instead of on ${day(billing.until)}, and it never renews.` diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx index f9b82a686..52613919a 100644 --- a/hosted/src/HostedPage.tsx +++ b/hosted/src/HostedPage.tsx @@ -17,7 +17,7 @@ import { siteButton, } from "dormouse-lib/site/docs-tokens"; import { providerNames, type Account, type Computer, type HostedVoice, type Passkey, type Provider, type Session } from "./api"; -import { RECENT_LOGIN_WINDOW, TERMS_VERSION } from "../server/policy-constants"; +import { CHECKOUT_CLOSED, RECENT_LOGIN_WINDOW, TERMS_VERSION, type Plan } from "../server/policy-constants"; import { BUTTON_CLASS, HEADING_CLASS, @@ -31,10 +31,11 @@ import { import { ConfirmDigits } from "./ConfirmDigits"; import { HostedShell } from "./HostedShell"; import { NoPlanOffer } from "./NoPlanOffer"; -import { planName, type BillingSummary, type Plan } from "./billing"; +import { PLAN_NAMES } from "../../website/src/lib/hosted-pricing.ts"; +import type { BillingSummary } from "../server/billing-summary"; import { Checkout } from "./Checkout"; import { CloseAccount } from "./Closure"; -import { FoundersOptIn, PlanConfirmation, PlanSection } from "./Plan"; +import { ChoosePlan, FoundersOptIn, ManageBilling, PlanConfirmation, PlanSection } from "./Plan"; import { WithdrawalAcknowledgement, WithdrawalFunction } from "./Withdrawal"; import type { Enrollment } from "./enrollment"; import type { Notice, PageError } from "./settle"; @@ -167,7 +168,7 @@ export function HostedPage({ /** What checkout and billing say before they have a plan to show. */ const billingUnread = billing === "closed" ? ( -

    Checkout is not open yet.

    +

    {CHECKOUT_CLOSED}

    ) : ( <>

    Billing could not load. Retry, or come back to this page later.

    @@ -329,10 +330,8 @@ export function HostedPage({ billingUnread ) : summary.plan ? ( <> -

    This account already has {planName(summary.plan)}.

    - +

    This account already has {PLAN_NAMES[summary.plan]}.

    + ) : ( // A new founding step asks for agreement again, both boxes unticked. @@ -355,7 +354,7 @@ export function HostedPage({ ) : ( <>

    This account has no plan.

    - Choose a plan + )} {founders} diff --git a/hosted/src/Plan.tsx b/hosted/src/Plan.tsx index 9ab738685..a54d65fcd 100644 --- a/hosted/src/Plan.tsx +++ b/hosted/src/Plan.tsx @@ -1,13 +1,12 @@ import { useState, type FormEvent, type ReactNode } from "react"; -import { CARD_CLASS, LINK_CLASS, MUTED_TEXT_CLASS } from "dormouse-lib/site/docs-tokens"; +import { CARD_CLASS, LINK_CLASS } from "dormouse-lib/site/docs-tokens"; import { isFounderName } from "../server/policy-constants"; -import { planName, type BillingSummary } from "./billing"; +import { PLAN_NAMES, planInterval } from "../../website/src/lib/hosted-pricing.ts"; +import type { BillingSummary } from "../server/billing-summary"; +import { day, refundWindow } from "./billing"; import { perInterval } from "./Checkout"; -import { BUTTON_CLASS, HEADING_CLASS, HELP_CLASS, INPUT_CLASS, LABEL_CLASS, LABEL_TEXT_CLASS } from "./classes"; -import { ModelForm, SupportLink } from "./Withdrawal"; - -const day = (iso: string) => new Date(iso).toLocaleDateString(); -const interval = (billing: BillingSummary) => (billing.plan === "monthly" ? "month" : "year"); +import { BUTTON_CLASS, CHECK_CLASS, HEADING_CLASS, HELP_CLASS, INPUT_CLASS, LABEL_CLASS } from "./classes"; +import { Facts, ModelForm, SupportLink } from "./Withdrawal"; /** * The held plan's state in one sentence. A lapsed plan with `entitled` still @@ -26,7 +25,7 @@ export function planStatus(billing: BillingSummary): string { : `Ends on ${day(billing.until)}. It will not renew.`; } -function ManageBilling({ busy, onManage }: { busy: string; onManage: () => void }) { +export function ManageBilling({ busy, onManage }: { busy: string; onManage: () => void }) { return (