diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index ac6ca27ef..5343b0034 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -19,9 +19,9 @@ Every Worker answers `/api/health` and `/api/ready`, 404s anything else under it **Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding, one per Worker. -**Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user and subscription columns, the relay inserts approved voice tokens ("Managed voice"), and both add to the daily counts ("Metrics"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`). +**Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user columns and `owner_access` view, the relay inserts approved voice tokens ("Managed voice"), and both add to the daily counts ("Metrics"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`). -**Must install released core, auth, and stripe packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth and billing migrations remain owned by their packages; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. **Never edit a merged migration**: a migrated database never reruns one, so append the next number (pinned by `hosted/server/tests/migrations.test.ts`). +**Must install released core, auth, and stripe packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth and billing migrations remain owned by their packages; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. **Never read `pgstencil_billing.accounts` or `checkouts`, or restate its access rule**: `@pgstencil/stripe`'s helpers and `owner_access` view answer those. **Never edit a merged migration**: a migrated database never reruns one, so append the next number (pinned by `hosted/server/tests/migrations.test.ts`). **Must declare every required peer dependency of the installed packages in `hosted/package.json`'s `dependencies`**, so they share Hosted's copy and Renovate updates them. **Never put an optional peer (pgstencil's tooling) in `dependencies`.** @@ -69,7 +69,7 @@ Source of truth: `termsRoutes` in `hosted/server/terms.ts`; `TERMS_VERSION` in ` **Must read the entitlement (`docs/specs/pricing.md` -> "Checkout and entitlement") on the server, per request, through one SQL predicate over the account's `"user"` row**, so a bearer and its owner's entitlement resolve in one query. -- **Must entitle an account only while it holds exactly one current subscription, `active` before its period end or `trialing` before its trial end** by the database's clock, as `@pgstencil/stripe`'s `status()` grants access; no other status grants anything. **Never entitle it while a payment dispute is open** ("Billing"). +- **Must entitle an account only while `@pgstencil/stripe`'s `owner_access` view grants it access** by the database's clock, as `status()` reads it: exactly one current subscription, `active` before its period end or `trialing` before its trial end. **Never entitle it while a payment dispute is open** ("Billing"). - **Must entitle `ADMIN_EMAIL` as a standing comp** while it is the account's verified email, one of the exceptions to "never email" ("Identity and login"); nothing else may key on an address. Source of truth: `entitledSql` and `entitled` in `hosted/server/entitlement.ts`; `cookieEntitled` in `hosted/server/account-gate.ts`. @@ -94,19 +94,19 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 - **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, every 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 `priceCatalog` does not price at `price` is expired and answers 409 `PRICE_CHANGED`. `GET` shows the latest while a subscription is held. +- **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 (`replace`); 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 `priceCatalog` does not price at `price` is refused (`expectPrice`) with 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 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 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: `billing.end` with reason `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 withdrawal (`dormouse_withdrawals`) is recorded with its acknowledgement, a `withdrawal` notice, then settled: its subscription ended at once (reason `withdrawal`), every paid invoice payment refunded in full, stamped once done; 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 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 resync, every 10 minutes from the account's Cron Trigger, a bounded batch of stale owners** (`reconcileStale`), 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 `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. +Source of truth: `billingSetup`, `withBilling`, `resyncBilling`, and `END_REASONS` 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 @@ -137,7 +137,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 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 expire an open checkout, then end every current subscription in Stripe (reason `closure`), before one statement records the closure (`dormouse_account_closures`) and deletes the `"user"` row**; rows without a foreign key stay. Its Burrows' sockets then close as a removal's do ("Relay sockets"). Checks: `docs/specs/security-hosted.md` -> "Account boundary". - **Must then refund what closing owes** (`docs/specs/pricing.md` -> "Checkout and entitlement"): a first payment while `purchaseStanding` counts the end a refund, a yearly or founding renewal paid within `REFUND_DAYS` of it. The closure is stamped settled once done; the cron retries for 30 days. Source of truth: `closureRoutes` in `hosted/server/closure.ts`; `CloseAccount` in `hosted/src/Closure.tsx`. @@ -331,7 +331,7 @@ Hosted's Workers count what they serve in aggregate daily rows; no page or deskt | `checkout.started` | `plan:ref` | checkout answers a URL | | `checkout.completed` | `plan:ref` | the webhook processes a completion (`completedCheckout`) | | `subscription.canceled` | plan | the webhook processes a purchase's end | -| `subscription.refunded` | plan | that end was `ENDED_FOR_REFUND` or within `REFUND_DAYS` | +| `subscription.refunded` | plan | that end's recorded reason was `refund`, or it came within `REFUND_DAYS` | - **Never count a label off its event's list** (`METRIC_LABELS`): the write throws. Row contents and readers: `docs/specs/security-hosted.md` -> "Account boundary". - **Never fail or delay the request a metric counts**: the write is unawaited, kept alive by `waitUntil`, and only logs failure. The webhook's counts commit in its transaction under a savepoint of their own, so a redelivery never counts twice and a failed count rolls back alone. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 8ae95f96f..21798decf 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -30,7 +30,7 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t - **FAIL IF** `S83` the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter. - **FAIL IF** `S84` an unused provider credential enables login, an unknown provider name is accepted, or incomplete enabled credentials silently degrade; inspect `providerBindings` in `hosted/server/policy.ts`. - **FAIL IF** `S85` speak or a voice token's mint admits an account the entitlement (`docs/specs/hosted.md` -> "Entitlement") does not, without rechecking per request, stores a voice token other than as its SHA-256, accepts one idle past `VOICE_TOKEN_IDLE_DAYS`, lets a bearer delete any token but its own, logs or stores speak text, forwards an ElevenLabs error body or status, or lets a binding or request field choose the upstream origin, path prefix, model, or format, or a voice outside `MANAGED_VOICES`; or the relay mints a voice token without redeeming a voice-purpose approval, or remote enrollment mints voice credentials; inspect `hosted/server/entitlement.ts`, `hosted/server/voice.ts`, and `hosted/server/relay-api.ts`. -- **FAIL IF** `S282` the entitlement admits an account but by `ADMIN_EMAIL` verified or by exactly one current subscription that is `active` before its period end or `trialing` before its trial end by the database's clock with no `dormouse_billing_disputes` row, or reads that state from anywhere but those synchronized rows in the query that resolves the bearer; inspect `entitledSql` in `hosted/server/entitlement.ts`. +- **FAIL IF** `S282` the entitlement admits an account but by `ADMIN_EMAIL` verified or by exactly one current subscription that is `active` before its period end or `trialing` before its trial end by the database's clock with no `dormouse_billing_disputes` row, or reads that state from anywhere but `@pgstencil/stripe`'s `pgstencil_billing.owner_access` view over the synchronized rows and that table, in the query that resolves the bearer; inspect `entitledSql` in `hosted/server/entitlement.ts`. - **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`. @@ -88,7 +88,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S107` a push endpoint is registered or fetched that `knownPushEndpoint` does not admit (`https:`, default port, no credentials, a listed push service's host), or a delivery follows a redirect or keeps more than 1 KiB of a response body; inspect `deliverPush` and `reasonOf`. - **FAIL IF** `S108` `RELAY_VAPID_PRIVATE_KEY` is anything but a relay Worker secret, a Wrangler `vars` entry or a preview config included, or push answers a key while the private key does not sign for it; inspect `pushConfigOf` in `hosted/server/relay-push.ts`, `vapidSigner` in `remote-lib-common/src/remote/web-push.ts`, and `hosted/scripts/preview.mjs`. - **FAIL IF** `S109` the WebCrypto sender stops reproducing the RFC 8291 Appendix A message byte for byte in `remote-lib-common/test/web-push.test.mjs`, or that test takes an expected value from the code under test. -- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables, `user`, `pgstencil_billing.subscriptions`, and `dormouse_billing_disputes` (voice-token insertion under "Managed voice" and the metric upsert (`docs/specs/hosted.md` -> "Metrics") excepted), or reads a column of the latter three but the entitlement's; inspect the relay bundle's imports and queries. +- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables, `user`, `pgstencil_billing.owner_access`, and `dormouse_billing_disputes` (voice-token insertion under "Managed voice" and the metric upsert (`docs/specs/hosted.md` -> "Metrics") excepted), or reads a column of the latter three but the entitlement's; inspect the relay bundle's imports and queries. - **FAIL IF** `S111` `RelayRoom` stores, logs, or decodes a frame or its `ct`, reads a frame other than through the shared frame layer (`readClientFrame`, `readBurrowFrame`), which parses only the routing envelope through the shared guards and copies `ct` field by field and reads it nowhere else, parses a frame before measuring its UTF-8 bytes against the shared `MAX_RELAY_FRAME_BYTES`, or puts anything but its account id in `ctx.storage` (routing state lives in hibernation attachments); `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules. - **FAIL IF** `S112` a `scripts/e2e-lint.mjs` rule whose `spec` is this file names a line it does not enforce, or one outside "Rendezvous boundary" and "Relay boundary". - **FAIL IF** `S113` a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. diff --git a/hosted/package.json b/hosted/package.json index 47a5a0cae..352ae87b8 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -25,13 +25,13 @@ "@fontsource/ubuntu-mono": "^5.3.0", "@fontsource/ubuntu-sans-mono": "^5.3.0", "@hono/node-server": "^2.0.10", - "@pgstencil/auth": "^0.4.1", - "@pgstencil/stripe": "^0.4.1", + "@pgstencil/auth": "^0.4.2", + "@pgstencil/stripe": "^0.4.2", "@phosphor-icons/react": "^2.1.10", "dormouse-lib": "workspace:*", "hono": "^4.13.8", "kysely": "^0.29.5", - "pgstencil": "^0.4.1", + "pgstencil": "^0.4.2", "react": "^19.2.6", "react-dom": "^19.2.6", "remote-lib-common": "workspace:*", diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts index b19c71321..2d033bcba 100644 --- a/hosted/server/billing-routes.ts +++ b/hosted/server/billing-routes.ts @@ -1,13 +1,13 @@ // Rules: docs/specs/hosted.md -> "Billing" and "Metrics"; docs/specs/pricing.md // -> "Checkout and entitlement". -import { BillingError, purchaseStanding, type Billing, type BillingDB, type Stripe } from "@pgstencil/stripe"; +import { BillingError, SUBSCRIBED, purchaseStanding, type Billing, type BillingDB, type Stripe } from "@pgstencil/stripe"; import type { Context, Hono } from "hono"; import type { Kysely } from "kysely"; import { readJson } from "remote-lib-common"; import { accountQuery, cookieLogin, smallBody, type AccountHost } from "./account-gate"; -import { foundingCohort, idOf, run, withBilling, type BillingSetup, type PriceCatalog } from "./billing"; +import { END_REASONS, 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 { entitledSql, subscribedSql } from "./entitlement"; import { checkoutRef, type MetricEvent } from "./metric-labels"; import { countMetricInTransaction, recordMetric } from "./metrics"; import { CHECKOUT_CLOSED, PRICE_CHANGED, REFUND_DAYS, TERMS_VERSION, isFounderName, isPlan, type Plan } from "./policy-constants"; @@ -42,8 +42,6 @@ export const BILLING_WEBHOOK_PATH = "/api/billing/webhook"; /** Stripe's events are larger than anything a page sends here. */ export const WEBHOOK_BODY_BYTES = 1024 * 1024; -/** The cancellation comment a subscription ended for a full refund or a final reversal carries. */ -export const ENDED_FOR_REFUND = "dormouse: full refund or final reversal"; const JSON_BODY_BYTES = 4096; const CHECKOUT_ID = /^[A-Za-z0-9_-]{1,64}$/; @@ -132,7 +130,7 @@ 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 && CURRENT_STATUSES.includes(status.subscription.status) ? status.subscription : null; + const held = status.subscription && SUBSCRIBED.includes(status.subscription.status) ? status.subscription : null; // A contract withdrawn from once is never offered again. const offer = held && account?.withdrawn?.subscription !== held.id @@ -179,35 +177,29 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) if (typeof body?.terms !== "string" || body.autoRenew !== true || !Number.isSafeInteger(body.price)) return c.json({ message: CONSENT_REQUIRED }, 400); if (body.terms !== TERMS_VERSION) return c.json({ message: TERMS_CHANGED }, 409); - // A checkout left open for another plan gives way to this one. - const open = await billing.db - .selectFrom("checkouts") - .select("plan") - .where("owner_id", "=", userId) - .where("status", "in", ["pending", "open"]) - .executeTakeFirst(); - if (open && open.plan !== plan) await billing.cancelCheckout(userId); - // Without an email, Stripe Checkout asks for one. - const { id, url } = await billing.checkout(userId, email, plan, ref === "none" ? {} : { ref }); - // The Price this checkout charges, a cohort sold out since the page - // loaded or one left open at an earlier step included: one the buyer - // was not shown is expired, so the page asks again at the new price. - const opened = await billing.db - .selectFrom("checkouts") - .select("price_id") - .where("id", "=", id) - .where("owner_id", "=", userId) - .executeTakeFirstOrThrow(); - if (catalog.prices.get(opened.price_id)?.usd !== body.price) { - await billing.cancelCheckout(userId); - return c.json({ message: PRICE_CHANGED }, 409); + // Without an email, Stripe Checkout asks for one. A checkout left open + // for another plan gives way to this one; one that would charge a + // Price the buyer was not shown, a cohort sold out since the page + // loaded or one left open at an earlier step included, is refused + // before Stripe opens it, so the page asks again at the new price. + let opened: { id: string; url: string; priceId: string }; + try { + opened = await billing.checkout(userId, email, plan, { + ...(ref !== "none" && { ref }), + replace: true, + expectPrice: (price) => catalog.prices.get(price)?.usd === body.price, + }); + } catch (error) { + if (error instanceof BillingError && error.code === "unexpected-price") return c.json({ message: PRICE_CHANGED }, 409); + throw error; } + const { id, url, priceId } = opened; // Recorded before the URL is answered, for the Price the buyer was shown. 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], + [id, TERMS_VERSION, userId, plan, priceId], ); recordMetric(c, host(c).databaseUrl, "checkout.started", `${plan}:${ref}`); return c.json({ url }); @@ -302,22 +294,10 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) }); } -/** 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 = idOf(customer); - if (!id) return null; - const row = await billing.db - .selectFrom("accounts") - .select("owner_id") - .where("customer_id", "=", id) - .executeTakeFirst(); - return row?.owner_id ?? null; -} - /** - * One verified event: pgstencil's synchronization, and in its transaction - * the counts, dispute suspensions, and their notices' records, so a - * redelivery repeats none. A full refund or a lost dispute then ends the + * One event, verified once: pgstencil's synchronization, and in its + * transaction the counts, dispute suspensions, and their notices' records, so + * a redelivery repeats none. A full refund or a lost dispute then ends the * subscription in Stripe, after the commit and on every delivery until it * has; a dispute's notice is then sent, and the cron retries one that fails. */ @@ -329,18 +309,20 @@ async function processEvent( context: NoticeContext, ) { const event = await billing.verifyWebhook(body, signature); - // A dispute names no customer, and its event may be stale: read its current - // status and its charge from Stripe, before any write. - let dispute: { id: string; charge: Stripe.Charge; owner: string | null; action: ReturnType } | null = - null; + // A dispute's event may be stale: read its current status, and its charge, + // from Stripe before any write. Its owner is pgstencil's, through the charge. + let dispute: { id: string; charge: Stripe.Charge; action: ReturnType } | null = null; if (event.type.startsWith("charge.dispute.")) { const current = await billing.stripe.disputes.retrieve((event.data.object as Stripe.Dispute).id, { expand: ["charge"], }); - const charge = current.charge as Stripe.Charge; - dispute = { id: current.id, charge, owner: await ownerOf(billing, charge.customer), action: disputeAction(current.status) }; + dispute = { id: current.id, charge: current.charge as Stripe.Charge, action: disputeAction(current.status) }; } - await billing.webhook(body, signature, async (verified, trx) => { + // Set by `apply`, which pgstencil runs inside the event's transaction, and + // skips for an event already processed. + let owner = null as string | null; + await billing.webhook(event, async (verified, trx, { ownerId, subscription: synced }) => { + owner = ownerId; const count = (event: MetricEvent, label: string) => countMetricInTransaction((text, values) => run(trx, text, values), event, label); const completed = await billing.completedCheckout(verified, trx); @@ -352,19 +334,21 @@ async function processEvent( const standing = purchaseStanding(subscription, { refundDays: REFUND_DAYS }); if (plan && (standing === "purchased" || standing === "refund-window")) await count( - standing === "refund-window" || subscription.cancellation_details?.comment === ENDED_FOR_REFUND + standing === "refund-window" || synced?.end_reason === END_REASONS.refund ? "subscription.refunded" : "subscription.canceled", plan, ); } - if (dispute?.owner) await recordDispute(trx, dispute.id, dispute.owner, dispute.action, billing.time.now()); + if (dispute && ownerId) await recordDispute(trx, dispute.id, ownerId, dispute.action, billing.time.now()); }); const refunded = event.type === "charge.refunded" ? (event.data.object as Stripe.Charge) : null; const reversed = refunded?.refunded ? refunded : dispute?.action === "end" ? dispute.charge : null; - if (reversed) await endForRefund(billing, reversed); - if (dispute?.owner) - for (const error of await sendNotices(billing, mail, context, { userId: dispute.owner })) + // A redelivery after a failed end runs no `apply`: the charge names the owner. + if (reversed || dispute) owner ??= await billing.ownerOfCustomer(idOf((reversed ?? dispute!.charge).customer)); + if (owner && reversed) await endForRefund(billing, owner, reversed); + if (owner && dispute) + for (const error of await sendNotices(billing, mail, context, { userId: owner })) console.error("A dispute notice is recorded but not sent; the cron retries it", error); } @@ -453,7 +437,7 @@ export async function resyncDisputes(billing: Billing): Promise try { const current = await billing.stripe.disputes.retrieve(disputeId, { expand: ["charge"] }); const action = disputeAction(current.status); - if (action === "end") await endForRefund(billing, current.charge as Stripe.Charge); + if (action === "end") await endForRefund(billing, userId, current.charge as Stripe.Charge); await billing.db.transaction().execute(async (trx) => { const now = billing.time.now(); await run(trx, `UPDATE public.dormouse_billing_disputes SET "checkedAt" = $2 WHERE "disputeId" = $1`, [ @@ -469,18 +453,16 @@ export async function resyncDisputes(billing: Billing): Promise } /** - * Ends at once, without proration or a final invoice, the subscription that - * `charge` paid for, now fully refunded or finally reversed, while it is its - * owner's current one. A charge for an older subscription, one that names no - * subscription, or one already ended, as on a redelivery once it has, ends - * nothing; the first two are logged for the operator. + * Ends at once, without proration or a final invoice (`billing.end`), the + * subscription that `charge`, `owner`'s, paid for, now fully refunded or + * finally reversed, while it is the owner's current one. A charge for an + * older subscription, one that names no subscription, or one already ended, + * as on a redelivery once it has, ends nothing; the first two are logged for + * the operator. */ -async function endForRefund(billing: Billing, charge: Stripe.Charge) { - const owner = await ownerOf(billing, charge.customer); - if (!owner) return; - await billing.reconcile(owner); +async function endForRefund(billing: Billing, owner: string, charge: Stripe.Charge) { const { subscription } = await billing.status(owner); - if (!subscription || !CURRENT_STATUSES.includes(subscription.status)) return; + if (!subscription || !SUBSCRIBED.includes(subscription.status)) return; const paidFor = await subscriptionPaidBy(billing, charge); if (paidFor !== subscription.id) { console.warn( @@ -488,10 +470,5 @@ async function endForRefund(billing: Billing, charge: Stripe.Charge) { ); return; } - await billing.stripe.subscriptions.cancel(subscription.id, { - cancellation_details: { comment: ENDED_FOR_REFUND }, - prorate: false, - invoice_now: false, - }); - await billing.reconcile(owner); + await billing.end(owner, subscription.id, { reason: END_REASONS.refund }); } diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index 0834d6bce..661a09ead 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -7,7 +7,6 @@ import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER, LIST_ANNUAL, LIST_MONTHLY } from import type { BillingEnv } from "./bindings"; import { resyncDisputes } from "./billing-routes"; import { retryClosures } from "./closure"; -import { grantsAccessSql } from "./entitlement"; import { runNotices } from "./notices"; import { settleWithdrawals } from "./withdrawal"; import { BILLING_RETURN_PATH, REFUND_DAYS, type Plan } from "./policy-constants"; @@ -17,6 +16,13 @@ export const DAY_MS = 24 * 60 * 60 * 1000; /** 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)); +/** + * The end reasons Hosted records on a subscription it ends (`billing.end`): a + * full refund or a final reversal, an EEA or UK withdrawal, an account's + * closure. The webhook reads one back to classify the end. + */ +export const END_REASONS = { refund: "refund", withdrawal: "withdrawal", closure: "closure" } as const; + /** A deployment's billing configuration, read from its bindings. */ export interface BillingSetup { secretKey: string; @@ -165,11 +171,8 @@ export function openCohort(gross: readonly number[], net: readonly number[]): Op */ export async function foundingPurchases(billing: Billing, founding: readonly string[]) { // A refund window of 0 counts every completed purchase, refunded or not. - const [gross, net] = await Promise.all( - [0, REFUND_DAYS].map(async (refundDays) => { - const counts = await billing.purchaseCounts(founding, { refundDays }); - return founding.map((price) => counts[price] ?? 0); - }), + const [gross, net] = (await billing.purchaseCounts(founding, { refundDays: [0, REFUND_DAYS] })).map((counts) => + founding.map((price) => counts[price] ?? 0), ); return { gross: gross!, net: net! }; } @@ -277,14 +280,8 @@ export async function resyncBilling( databaseUrl, 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)); + const stale = await billing.reconcileStale({ limit: RESYNC_OWNERS }); + failed.push(...stale.failed.map(({ error }) => error)); failed.push(...(await settleWithdrawals(billing))); failed.push(...(await retryClosures(billing))); failed.push(...(await resyncDisputes(billing))); diff --git a/hosted/server/closure.ts b/hosted/server/closure.ts index 80c283338..f012994e9 100644 --- a/hosted/server/closure.ts +++ b/hosted/server/closure.ts @@ -1,12 +1,11 @@ // Rules: docs/specs/hosted.md -> "Account closure". -import { BillingError, purchaseStanding, type Billing, type BillingDB, type Stripe } from "@pgstencil/stripe"; +import { BillingError, SUBSCRIBED, purchaseStanding, type Billing, type BillingDB, type Stripe } from "@pgstencil/stripe"; import type { Context, Hono } from "hono"; import type { Selectable } from "kysely"; import { readJson } from "remote-lib-common"; 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 { DAY_MS, END_REASONS, run, withBilling, type BillingSetup } from "./billing"; import { RECENT_LOGIN_WINDOW, REFUND_DAYS, type Plan } from "./policy-constants"; import { refundPaidInvoices } from "./withdrawal"; @@ -24,17 +23,14 @@ export const CLOSE_RECENT_LOGIN = `Sign in again to close your account. Closing /** 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"; - /** - * Expires `owner`'s open checkout, then cancels in Stripe at once, without - * proration or a final invoice, every subscription of theirs still current; - * answers the ones it canceled. An owner billing never knew has none. + * Expires `owner`'s open checkout, then ends in Stripe at once, without + * proration or a final invoice (`billing.end`), every subscription of theirs + * still current; answers the ones it ended. An owner billing never knew has + * none. */ async function endSubscriptions(billing: Billing, owner: string): Promise { - const known = await billing.db.selectFrom("accounts").select("owner_id").where("owner_id", "=", owner).executeTakeFirst(); - if (!known) return []; + if (!(await billing.customerOf(owner))) return []; // A checkout Stripe completed meanwhile is confirmed here, and so ended below. await billing.cancelCheckout(owner); await billing.reconcile(owner); @@ -42,15 +38,9 @@ async function endSubscriptions(billing: Billing, owner: string): Promise< .selectFrom("subscriptions") .select("id") .where("owner_id", "=", owner) - .where("status", "in", [...CURRENT_STATUSES]) + .where("status", "in", SUBSCRIBED) .execute(); - for (const { id } of current) - await billing.stripe.subscriptions.cancel(id, { - cancellation_details: { comment: ENDED_FOR_CLOSURE }, - prorate: false, - invoice_now: false, - }); - if (current.length) await billing.reconcile(owner); + for (const { id } of current) await billing.end(owner, id, { reason: END_REASONS.closure }); return current.map(({ id }) => id); } @@ -159,7 +149,7 @@ export function closureRoutes(app: Hono, host: (c: Context) => ClosureHost) const [held] = await accountQuery<{ held: boolean }>( deployment, `SELECT EXISTS (SELECT FROM pgstencil_billing.subscriptions WHERE owner_id = $1 AND status = ANY ($2::text[])) AS held`, - [userId, CURRENT_STATUSES], + [userId, SUBSCRIBED], ); if (held?.held) return c.json({ message: CLOSE_UNAVAILABLE }, 409); await close([]); diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts index 7eeb0cdda..fdfe65e7f 100644 --- a/hosted/server/entitlement.ts +++ b/hosted/server/entitlement.ts @@ -8,33 +8,18 @@ export const ADMIN_EMAIL = "ned.twigg@diffplug.com"; if (!/^[^'\\]+$/.test(ADMIN_EMAIL)) throw new Error("ADMIN_EMAIL cannot be inlined into SQL"); /** - * The statuses `@pgstencil/stripe`'s `status()` counts as a current - * subscription; access needs exactly one of them. - */ -export const CURRENT_STATUSES: readonly string[] = ["trialing", "active", "past_due", "unpaid", "paused", "incomplete"]; - -const CURRENT = CURRENT_STATUSES.map((status) => `'${status}'`).join(", "); - -/** - * Whether subscription row `s` grants access now, by the database's clock: - * `active` before its period end or `trialing` before its trial end, as - * `status()` reads it. No other status grants anything, `past_due` included. - */ -export const grantsAccessSql = (s: string) => - `((${s}.status = 'active' AND ${s}.period_end > now()) - OR (${s}.status = 'trialing' AND ${s}.trial_end > now()))`; - -/** - * Whether the SQL expression `owner` holds exactly one current subscription - * and it grants access, as `status()` decides; with `prices`, an SQL `text[]` - * expression, only while that subscription is on one of them. + * Whether the SQL expression `owner` holds access now, by the database's + * clock, as `@pgstencil/stripe`'s `status()` decides: both read its + * `owner_access` view, which holds the rule (exactly one current + * subscription, `active` before its period end or `trialing` before its + * trial end). With `prices`, an SQL `text[]` expression, only while that + * subscription is on one of them. */ export function subscribedSql(owner: string, prices?: string): string { const on = prices ? ` AND entitling.price_id = ANY (${prices})` : ""; // An alias no caller's query uses, so `owner` can name the outer row. - return `coalesce((SELECT count(*) FILTER (WHERE entitling.status IN (${CURRENT})) = 1 - AND bool_or(${grantsAccessSql("entitling")}${on}) - FROM pgstencil_billing.subscriptions entitling WHERE entitling.owner_id = ${owner}), false)`; + return `EXISTS (SELECT FROM pgstencil_billing.owner_access entitling + WHERE entitling.owner_id = ${owner} AND entitling.access_until > now()${on})`; } /** Whether the SQL expression `owner` has a payment dispute open, which suspends its subscription. */ diff --git a/hosted/server/runtime-roles.sql b/hosted/server/runtime-roles.sql index ae8936b15..768276e43 100644 --- a/hosted/server/runtime-roles.sql +++ b/hosted/server/runtime-roles.sql @@ -62,12 +62,14 @@ $$; GRANT USAGE ON SCHEMA public TO dormouse_relay, dormouse_voice; -- Both: the entitlement (hosted/server/entitlement.ts) reads the owner's --- address and its verification, the owner's subscriptions, and whether a --- payment dispute suspends them. +-- address and its verification, until when @pgstencil/stripe's owner_access +-- view grants the owner access, and whether a payment dispute suspends them. +-- The view runs with its owner's privileges, so neither role reads the +-- subscriptions table beneath it. GRANT SELECT (id, email, "emailVerified") ON "user" TO dormouse_relay, dormouse_voice; GRANT USAGE ON SCHEMA pgstencil_billing TO dormouse_relay, dormouse_voice; -GRANT SELECT (owner_id, status, period_end, trial_end) - ON pgstencil_billing.subscriptions TO dormouse_relay, dormouse_voice; +GRANT SELECT (owner_id, access_until) + ON pgstencil_billing.owner_access TO dormouse_relay, dormouse_voice; GRANT SELECT ("userId") ON dormouse_billing_disputes TO dormouse_relay, dormouse_voice; -- Both: their metrics' upsert-increment (hosted/server/metrics.ts), whose @@ -109,7 +111,7 @@ GRANT UPDATE (count) ON dormouse_voice_usage TO dormouse_voice; -- The account Worker's role, made by hand (hosted/README.md), owns billing: -- @pgstencil/stripe reads and writes every pgstencil_billing table, and the --- entitlement reads its subscriptions. Granted here, after every migration, +-- entitlement reads its owner_access view, which ALL TABLES covers. Granted here, after every migration, -- because its schema is the package's and no hand grant can precede it. -- Additive only: nothing here revokes from it, and a database without the -- role (tests, previews) skips it. diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts index 5313bf979..958f57d78 100644 --- a/hosted/server/tests/billing-routes.test.ts +++ b/hosted/server/tests/billing-routes.test.ts @@ -6,12 +6,11 @@ import { createTestContext } from "pgstencil/testing"; import { queryDatabase } from "pgstencil/postgres"; import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER, LIST_ANNUAL, LIST_MONTHLY } from "../../../website/src/lib/hosted-pricing"; import { WITHDRAWAL_STATEMENT } from "../../../website/src/lib/hosted-withdrawal"; -import { resyncBilling, type BillingSetup } from "../billing"; +import { END_REASONS, resyncBilling, type BillingSetup } from "../billing"; import { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } from "../../../website/src/lib/hosted-cohorts"; import { BILLING_WEBHOOK_PATH, CONSENT_REQUIRED, - ENDED_FOR_REFUND, FOUNDERS_ONLY, TERMS_CHANGED, WEBHOOK_BODY_BYTES, @@ -19,10 +18,10 @@ import { billingRoutes, disputeAction, } from "../billing-routes"; -import { CLOSE_RECENT_LOGIN, CLOSE_UNAVAILABLE, ENDED_FOR_CLOSURE, closureRoutes } from "../closure"; +import { CLOSE_RECENT_LOGIN, CLOSE_UNAVAILABLE, closureRoutes } from "../closure"; 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 { WITHDRAWAL_OPEN_DAYS } from "../withdrawal"; import { migrations } from "../migrations"; import { DEV_FOUNDING_PRICES } from "../billing-dev"; import { CHECKOUT_CLOSED, PRICE_CHANGED, TERMS_VERSION } from "../policy-constants"; @@ -159,6 +158,12 @@ async function fixture({ closed = false } = {}) { await deliver(); return { session, subscription }; }; + /** Each subscription Hosted ended (`billing.end`), and the reason it recorded. */ + const ended = () => + sql<{ subscription: string; reason: string }>( + `SELECT id AS subscription, end_reason AS reason FROM pgstencil_billing.subscriptions + WHERE end_reason IS NOT NULL ORDER BY id`, + ); const metrics = () => sql<{ event: string; label: string; count: string }>( `SELECT event, label, count FROM dormouse_metrics_daily WHERE event LIKE 'checkout.%' OR event LIKE 'subscription.%' @@ -185,6 +190,7 @@ async function fixture({ closed = false } = {}) { deliver, buy, metrics, + ended, entitledOn, email, mail, @@ -412,7 +418,7 @@ test("checkout starts only after agreement to the current terms and to automatic expect(await consents()).toHaveLength(2); }); -test("checkout refuses a price the page did not show, expiring what it opened, so the consent records the one it did", async ({ +test("checkout refuses a price the page did not show before opening one, expiring one left open, so the consent records the one it did", async ({ onTestFinished, }) => { const f = await fixture(); @@ -426,7 +432,7 @@ test("checkout refuses a price the page did not show, expiring what it opened, s const changed = await start(member, plan, price); expect([changed.status, await changed.json()], plan).toEqual([409, { message: PRICE_CHANGED }]); } - expect(statuses()).toEqual(["expired", "expired"]); + expect(statuses()).toEqual([]); // A founding checkout opens at the first step, then the cohort sells out. const late = await f.account(); @@ -446,7 +452,7 @@ test("checkout refuses a price the page did not show, expiring what it opened, s expect((await start(member, "founding", FOUNDING_LADDER[0]!)).status).toBe(409); // The reloaded page shows the next step, but the checkout left open would charge the first: it is expired. expect((await start(late, "founding", FOUNDING_LADDER[1]!)).status).toBe(409); - expect(statuses().slice(-2)).toEqual(["expired", "expired"]); + expect(statuses()).toEqual(["expired"]); expect(await consents()).toEqual([{ priceId: f.setup.founding[0] }]); // Asked again, it opens at the price shown, and its consent says so. expect((await start(late, "founding", FOUNDING_LADDER[1]!)).status).toBe(200); @@ -541,7 +547,7 @@ test("the webhook verifies the raw body before any write, caps it, and refuses a expect(await f.sql(`SELECT id FROM pgstencil_billing.events`)).toEqual([{ id: event.id }]); // A failure before the commit answers 503, which Stripe retries, and the event stays unprocessed. const { body: disputed, signature: disputeSignature } = f.dev.signed( - f.event("charge.dispute.created", { id: "du_unknown", object: "dispute" }), + f.event("charge.dispute.created", { id: "du_unknown", object: "dispute", charge: "ch_for_du_unknown" }), ); expect((await f.webhook(disputed, disputeSignature)).status).toBe(503); expect(await f.sql(`SELECT count(*)::int AS n FROM pgstencil_billing.events WHERE processed_at IS NOT NULL`)).toEqual([{ n: 1 }]); @@ -572,7 +578,8 @@ test("a full refund ends the subscription it paid for at once; a partial one cha f.time.advanceMilliseconds(31 * 24 * 60 * 60 * 1000); const full = charge(true); expect((await f.send(full)).status).toBe(200); - expect(f.cancels).toEqual([{ subscription: subscription.id, comment: ENDED_FOR_REFUND }]); + expect(f.cancels).toEqual([{ subscription: subscription.id }]); + expect(await f.ended()).toEqual([{ subscription: subscription.id, reason: END_REASONS.refund }]); expect(await f.entitledOn(member.id)).toBe(false); expect(await member.summary()).toMatchObject({ plan: null, active: false, entitled: false }); await f.deliver(); @@ -596,6 +603,7 @@ test("an open dispute suspends without ending, closing it in the account's favor return f.event(status === "needs_response" ? "charge.dispute.created" : "charge.dispute.closed", { id, object: "dispute", + charge: `ch_for_${id}`, status, }); }; @@ -615,11 +623,52 @@ test("an open dispute suspends without ending, closing it in the account's favor expect((await f.send(dispute("du_2", "needs_response"))).status).toBe(200); expect(await f.entitledOn(member.id)).toBe(false); expect((await f.send(dispute("du_2", "lost"))).status).toBe(200); - expect(f.cancels).toEqual([{ subscription: subscription.id, comment: ENDED_FOR_REFUND }]); + expect(f.cancels).toEqual([{ subscription: subscription.id }]); + expect(await f.ended()).toEqual([{ subscription: subscription.id, reason: END_REASONS.refund }]); expect(await f.sql(`SELECT "disputeId" FROM dormouse_billing_disputes`)).toEqual([]); expect(await member.summary()).toMatchObject({ plan: null, entitled: false, suspended: false }); }); +test("an end that fails after the commit is retried by the redelivery, for a full refund and a lost dispute", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const refundedMember = await f.account(); + const { session: refundedSession, subscription: refunded } = await f.buy(refundedMember, "monthly"); + f.paidInvoices.set("pi_refunded", refunded.id); + const disputedMember = await f.account(); + const { session: disputedSession, subscription: disputed } = await f.buy(disputedMember, "yearly"); + f.paidInvoices.set("pi_du_lost", disputed.id); + f.disputes.set("du_lost", { status: "lost", customer: disputedSession.customer as string, paymentIntent: "pi_du_lost" }); + for (const [event, member, subscription] of [ + [ + f.event("charge.refunded", { + id: "ch_refunded", + object: "charge", + customer: refundedSession.customer, + payment_intent: "pi_refunded", + refunded: true, + }), + refundedMember, + refunded, + ], + [f.event("charge.dispute.closed", { id: "du_lost", object: "dispute", charge: "ch_for_du_lost" }), disputedMember, disputed], + ] as const) { + // The event commits, then Stripe refuses the cancellation: 503, so Stripe redelivers. + f.failing.cancels = 1; + expect((await f.send(event)).status, event.type).toBe(503); + expect(await f.entitledOn(member.id), event.type).toBe(true); + // The redelivery finds the event processed, and still ends the subscription. + expect((await f.send(event)).status, event.type).toBe(200); + expect(f.cancels.at(-1), event.type).toEqual({ subscription: subscription.id }); + expect(await f.entitledOn(member.id), event.type).toBe(false); + } + expect(await f.ended()).toEqual( + [refunded.id, disputed.id].sort().map((subscription) => ({ subscription, reason: END_REASONS.refund })), + ); +}); + test("the cron rereads open disputes from Stripe, so a missed close event restores once or ends", async ({ onTestFinished }) => { const f = await fixture(); onTestFinished(f.close); @@ -631,7 +680,7 @@ test("the cron rereads open disputes from Stripe, so a missed close event restor f.paidInvoices.set("pi_du_lost", lostSubscription.id); const open = (id: string, customer: string) => { f.disputes.set(id, { status: "needs_response", customer, paymentIntent: `pi_${id}` }); - return f.send(f.event("charge.dispute.created", { id, object: "dispute" })); + return f.send(f.event("charge.dispute.created", { id, object: "dispute", charge: `ch_for_${id}` })); }; expect((await open("du_won", wonSession.customer as string)).status).toBe(200); expect((await open("du_lost", lostSession.customer as string)).status).toBe(200); @@ -648,7 +697,8 @@ test("the cron rereads open disputes from Stripe, so a missed close event restor await resync(); expect(await f.entitledOn(won.id)).toBe(true); expect(restored().map((mail) => mail.to[0])).toEqual([f.dev.customers.get(wonSession.customer as string)!.email]); - expect(f.cancels).toEqual([{ subscription: lostSubscription.id, comment: ENDED_FOR_REFUND }]); + expect(f.cancels).toEqual([{ subscription: lostSubscription.id }]); + expect(await f.ended()).toEqual([{ subscription: lostSubscription.id, reason: END_REASONS.refund }]); expect(await f.sql(`SELECT "disputeId" FROM dormouse_billing_disputes`)).toEqual([]); await resync(); expect([restored().length, f.cancels.length]).toEqual([1, 1]); @@ -664,14 +714,15 @@ test("a dispute on a closed account still records, and a lost one still runs its await f.deliver(); const dispute = (status: string) => { f.disputes.set("du_closed", { status, customer: session.customer as string, paymentIntent: "pi_du_closed" }); - return f.send(f.event("charge.dispute.updated", { id: "du_closed", object: "dispute", status })); + return f.send(f.event("charge.dispute.updated", { id: "du_closed", object: "dispute", charge: "ch_for_du_closed", status })); }; expect((await dispute("needs_response")).status).toBe(200); expect(await f.sql(`SELECT "userId" FROM dormouse_billing_disputes`)).toEqual([{ userId: member.id }]); expect((await dispute("lost")).status).toBe(200); expect(await f.sql(`SELECT "userId" FROM dormouse_billing_disputes`)).toEqual([]); // Closing already ended the subscription; the lost dispute ends nothing more. - expect(f.cancels.map((cancel) => cancel.comment)).toEqual([ENDED_FOR_CLOSURE]); + expect(f.cancels).toEqual([{ subscription: subscription.id }]); + expect(await f.ended()).toEqual([{ subscription: subscription.id, reason: END_REASONS.closure }]); }); test("a purchase ending otherwise counts as canceled, and one ending within the refund window as refunded", async ({ @@ -722,7 +773,8 @@ test("a full refund of a charge for an earlier subscription, or one naming none, expect(await f.entitledOn(member.id)).toBe(true); // The current subscription's own charge still ends it. expect((await f.send(refund("pi_current"))).status).toBe(200); - expect(f.cancels).toEqual([{ subscription: current.id, comment: ENDED_FOR_REFUND }]); + expect(f.cancels).toEqual([{ subscription: current.id }]); + expect(await f.ended()).toEqual([{ subscription: current.id, reason: END_REASONS.refund }]); }); test("a count that fails rolls back alone and is logged: the webhook still commits and answers 200", async ({ @@ -790,7 +842,8 @@ test("an EEA or UK consumer withdraws online: recorded, acknowledged by email, r withdrawal: null, withdrawn: { reference: row!.id, subscription: subscription.id, plan: "yearly", acknowledged: true, settled: true }, }); - expect(f.cancels).toEqual([{ subscription: subscription.id, comment: ENDED_FOR_WITHDRAWAL }]); + expect(f.cancels).toEqual([{ subscription: subscription.id }]); + expect(await f.ended()).toEqual([{ subscription: subscription.id, reason: END_REASONS.withdrawal }]); expect(f.refunds).toEqual([ { paymentIntent: "pi_w0", withdrawal: null }, { paymentIntent: "pi_w1", withdrawal: row!.id }, @@ -905,7 +958,8 @@ test("closing an account needs a recent login, cancels its subscription in Strip expect(f.cancels).toEqual([]); expect((await close({ close: true })).status).toBe(204); - expect(f.cancels).toEqual([{ subscription: subscription.id, comment: ENDED_FOR_CLOSURE }]); + expect(f.cancels).toEqual([{ subscription: subscription.id }]); + expect(await f.ended()).toEqual([{ subscription: subscription.id, reason: END_REASONS.closure }]); expect(await f.sql(`SELECT status FROM pgstencil_billing.subscriptions WHERE id = $1`, [subscription.id])).toEqual([ { status: "canceled" }, ]); @@ -995,9 +1049,10 @@ test("closing answers billing's refusal, such as a checkout still being created, const f = await fixture(); onTestFinished(f.close); const member = await f.account(); - await f.sql(`INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at) VALUES ($1, 'x@example.test', now())`, [ - member.id, - ]); + await f.sql( + `INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at, customer_id) VALUES ($1, 'x@example.test', now(), 'cus_creating')`, + [member.id], + ); await f.sql( `INSERT INTO pgstencil_billing.checkouts (id, owner_id, plan, price_id, trial_days, status, created_at, expires_at) VALUES ('creating', $1, 'monthly', $2, 0, 'pending', now(), now() + interval '1 hour')`, @@ -1179,7 +1234,7 @@ test("a dispute's suspension and restoration are each emailed once to the billin const billingEmail = f.dev.customers.get(session.customer as string)!.email; const dispute = (id: string, status: string) => { f.disputes.set(id, { status, customer: session.customer as string, paymentIntent: `pi_${id}` }); - return f.event("charge.dispute.updated", { id, object: "dispute", status }); + return f.event("charge.dispute.updated", { id, object: "dispute", charge: `ch_for_${id}`, status }); }; const subjects = () => f.email.all().map((mail) => [mail.to[0], mail.subject]); @@ -1209,9 +1264,9 @@ test("a dispute's suspension and restoration are each emailed once to the billin const unsent = () => f.sql(`SELECT kind FROM dormouse_billing_notices WHERE "sentAt" IS NULL ORDER BY kind`); f.disputes.set("du_back", { status: "needs_response", customer: otherSession.customer as string, paymentIntent: "pi_du_back" }); f.mailing.failures = 2; - expect((await f.send(f.event("charge.dispute.created", { id: "du_back", object: "dispute" }))).status).toBe(200); + expect((await f.send(f.event("charge.dispute.created", { id: "du_back", object: "dispute", charge: "ch_for_du_back" }))).status).toBe(200); f.disputes.set("du_back", { status: "won", customer: otherSession.customer as string, paymentIntent: "pi_du_back" }); - expect((await f.send(f.event("charge.dispute.closed", { id: "du_back", object: "dispute" }))).status).toBe(200); + expect((await f.send(f.event("charge.dispute.closed", { id: "du_back", object: "dispute", charge: "ch_for_du_back" }))).status).toBe(200); expect([f.mailing.failures, await unsent()]).toEqual([0, [{ kind: "dispute_restored" }, { kind: "dispute_suspended" }]]); f.time.advanceMilliseconds(10 * 60 * 1000); await resyncBilling(f.setup, f.database.url, origin, f.mail); diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts index 1985f5f7c..221423182 100644 --- a/hosted/server/tests/billing.test.ts +++ b/hosted/server/tests/billing.test.ts @@ -326,6 +326,8 @@ test("the account Worker's cron resyncs through Stripe's API in workerd, and onl ); onTestFinished(() => mf.dispose()); await mf.ready; + // The Worker's clock is the fixture's, which the stale renewal is stale by. + await mf.dispatchFetch(origin + "/__test/time", { method: "POST", body: f.time.now().toISOString() }); // Without @cloudflare/workers-types, the Fetcher's scheduled() is untyped. return (await mf.getWorker()) as unknown as { scheduled(options: { cron: string }): Promise<{ outcome: string }>; diff --git a/hosted/server/tests/runtime-roles.test.ts b/hosted/server/tests/runtime-roles.test.ts index e1308b100..f888113ed 100644 --- a/hosted/server/tests/runtime-roles.test.ts +++ b/hosted/server/tests/runtime-roles.test.ts @@ -95,12 +95,10 @@ const EXPECTED: Record = { "dormouse_relay_setup_tokens: DELETE, INSERT, SELECT", "dormouse_voice_tokens.hash: INSERT", "dormouse_voice_tokens.userId: INSERT", + "owner_access.access_until: SELECT", + "owner_access.owner_id: SELECT", "schema pgstencil_billing: USAGE", "schema public: USAGE", - "subscriptions.owner_id: SELECT", - "subscriptions.period_end: SELECT", - "subscriptions.status: SELECT", - "subscriptions.trial_end: SELECT", "user.email: SELECT", "user.emailVerified: SELECT", "user.id: SELECT", @@ -118,12 +116,10 @@ const EXPECTED: Record = { "dormouse_voice_tokens: DELETE", "dormouse_voice_usage.count: UPDATE", "dormouse_voice_usage: INSERT, SELECT", + "owner_access.access_until: SELECT", + "owner_access.owner_id: SELECT", "schema pgstencil_billing: USAGE", "schema public: USAGE", - "subscriptions.owner_id: SELECT", - "subscriptions.period_end: SELECT", - "subscriptions.status: SELECT", - "subscriptions.trial_end: SELECT", "user.email: SELECT", "user.emailVerified: SELECT", "user.id: SELECT", @@ -146,9 +142,11 @@ const outcome = (worker: Worker, text: string) => (error: { code?: string }) => error.code, ); -/** Billing beyond the entitlement's subscription columns, refused to both roles. */ +/** Billing beyond the entitlement's `owner_access` columns, refused to both roles. */ const BILLING_REFUSED = [ - `SELECT price_id FROM pgstencil_billing.subscriptions`, + `SELECT owner_id FROM pgstencil_billing.subscriptions`, + `SELECT price_id FROM pgstencil_billing.owner_access`, + `SELECT status FROM pgstencil_billing.owner_access`, `SELECT customer_id FROM pgstencil_billing.accounts`, `SELECT id FROM pgstencil_billing.checkouts`, `SELECT id FROM pgstencil_billing.events`, @@ -159,8 +157,8 @@ const BILLING_REFUSED = [ `DELETE FROM dormouse_billing_disputes`, ]; -/** What the entitlement reads of the subscriptions, which both roles may. */ -const ENTITLEMENT_READ = `SELECT owner_id, status, period_end, trial_end FROM pgstencil_billing.subscriptions +/** What the entitlement reads of billing, which both roles may. */ +const ENTITLEMENT_READ = `SELECT owner_id, access_until FROM pgstencil_billing.owner_access WHERE NOT EXISTS (SELECT FROM dormouse_billing_disputes d WHERE d."userId" = owner_id)`; test("the relay's role is refused the account's tables, the user row's other columns and writes, voice but a token's mint, and Burrow removal", async () => { @@ -257,7 +255,7 @@ test("once both roles exist, a migration role without CREATEROLE reapplies the f ).resolves.toBeUndefined(); }); -test("the account's hand-made role, where it exists, is granted every billing table and nothing taken away", async () => { +test("the account's hand-made role, where it exists, is granted every billing table and the access view, and nothing taken away", async () => { // The name production's account Hyperdrive connects as. expect(PRODUCTION.account.role).toBe("dormouse_app"); const granted = await withClient(context.database.url, async (db) => { @@ -270,7 +268,7 @@ test("the account's hand-made role, where it exists, is granted every billing ta `SELECT c.relname AS table, has_schema_privilege('dormouse_app', 'pgstencil_billing', 'USAGE') AND has_table_privilege('dormouse_app', c.oid, 'SELECT, INSERT, UPDATE, DELETE') AS ok FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = 'pgstencil_billing' AND c.relkind = 'r' + WHERE n.nspname = 'pgstencil_billing' AND c.relkind IN ('r', 'v') UNION ALL SELECT 'session', has_table_privilege('dormouse_app', '"session"', 'SELECT') ORDER BY 1`, ); @@ -280,6 +278,6 @@ test("the account's hand-made role, where it exists, is granted every billing ta } }); expect(granted).toEqual( - ["accounts", "checkouts", "events", "session", "subscriptions"].map((table) => ({ table, ok: true })), + ["accounts", "checkouts", "events", "owner_access", "session", "subscriptions"].map((table) => ({ table, ok: true })), ); }); diff --git a/hosted/server/tests/stripe-fake.ts b/hosted/server/tests/stripe-fake.ts index 252237882..4f97734cf 100644 --- a/hosted/server/tests/stripe-fake.ts +++ b/hosted/server/tests/stripe-fake.ts @@ -13,7 +13,7 @@ export const CHANGED_USD = { monthly: 12, yearly: 120 } as const; /** * StripeDev behind a proxy that adds what billing's refund, dispute, * withdrawal, and closure handling, and the price changes, call and StripeDev - * does not model: a dispute's retrieval, with its charge expanded, a + * does not model: a dispute's retrieval, with its charge expanded, and its charge's, a * subscription's immediate cancellation, retrieval, and move to another * Price, a customer's retrieval with its billing country, a subscription's * paid invoices, why each was billed and when it was paid, and their @@ -48,8 +48,8 @@ export async function stripeFake(time: Time, random: RandomSource) { const paidInvoices = new Map(); /** PaymentIntent id → why its invoice was billed, and when it was paid; else a first payment, paid at no known time. */ const invoiceFacts = new Map(); - /** Each cancellation billing asked for: the subscription and its comment. */ - const cancels: { subscription: string; comment: string | null }[] = []; + /** Each cancellation billing asked for. */ + const cancels: { subscription: string }[] = []; /** Customer id → its billing address's country; none, no address. */ const countries = new Map(); /** Each refund made: its PaymentIntent and its metadata's withdrawal, or closure when it names one. */ @@ -76,6 +76,13 @@ export async function stripeFake(time: Time, random: RandomSource) { charge: { id: `ch_for_${dispute[1]}`, object: "charge", customer: found.customer, payment_intent: found.paymentIntent }, }); } + // A dispute's charge, which a dispute event names by id. + const charge = /^\/v1\/charges\/ch_for_([^/]+)$/.exec(url.pathname); + if (charge && req.method === "GET") { + const found = disputes.get(charge[1]!); + if (!found) return json(404, { error: { message: "No such charge" } }); + return json(200, { id: `ch_for_${charge[1]}`, object: "charge", customer: found.customer, payment_intent: found.paymentIntent }); + } const customer = /^\/v1\/customers\/([^/]+)$/.exec(url.pathname); if (customer && req.method === "GET") { reads.customers++; @@ -179,14 +186,9 @@ export async function stripeFake(time: Time, random: RandomSource) { failing.cancels--; return json(500, { error: { type: "api_error", message: "Stripe fake outage" } }); } - // The SDK sends a DELETE's parameters in its query. - const comment = new URLSearchParams(raw || url.search).get("cancellation_details[comment]"); - cancels.push({ subscription: subscription[1]!, comment }); + cancels.push({ subscription: subscription[1]! }); dev.transition(subscription[1]!, "cancel"); - // Stripe carries the comment on the subscription it ends. - const ended = dev.events.at(-1)!.data.object as Stripe.Subscription; - ended.cancellation_details = { comment } as Stripe.Subscription.CancellationDetails; - return json(200, ended); + return json(200, dev.events.at(-1)!.data.object); } const forwarded = await fetch(dev.origin + url.pathname + url.search, { method: req.method, diff --git a/hosted/server/withdrawal.ts b/hosted/server/withdrawal.ts index c4001d78c..afcb38090 100644 --- a/hosted/server/withdrawal.ts +++ b/hosted/server/withdrawal.ts @@ -1,10 +1,9 @@ // Rules: docs/specs/hosted.md -> "Billing"; docs/specs/pricing.md -> // "Checkout and entitlement". -import type { Billing, Stripe } from "@pgstencil/stripe"; -import { DAY_MS, idOf, run } from "./billing"; +import { SUBSCRIBED, type Billing, type Stripe } from "@pgstencil/stripe"; +import { DAY_MS, END_REASONS, 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 @@ -29,9 +28,6 @@ const WITHDRAWAL_COUNTRIES: ReadonlySet = new Set([ ...["GB", "GG", "GI", "IM", "JE"], ]); -/** The cancellation comment a subscription ended by withdrawal carries. */ -export const ENDED_FOR_WITHDRAWAL = "dormouse: EEA or UK withdrawal"; - /** How long the cron keeps retrying a withdrawal's settlement. */ const RETRY_DAYS = 30; @@ -65,26 +61,22 @@ const contacts = new WeakMap, ownerId: string, { cached = false } = {}): Promise { - const account = await billing.db - .selectFrom("accounts") - .select("customer_id") - .where("owner_id", "=", ownerId) - .executeTakeFirst(); - if (!account?.customer_id) return null; + const customerId = await billing.customerOf(ownerId); + if (!customerId) return null; const now = billing.time.now().getTime(); let known = contacts.get(billing.stripe); if (!known) contacts.set(billing.stripe, (known = new Map())); - const hit = known.get(account.customer_id); + const hit = known.get(customerId); if (cached && hit && now - hit.at < CONTACT_CACHE_MS) return hit.contact; try { - const customer = await billing.stripe.customers.retrieve(account.customer_id); + const customer = await billing.stripe.customers.retrieve(customerId); const contact = customer.deleted ? null : { email: customer.email ?? null, country: customer.address?.country?.toUpperCase() ?? null }; - known.set(account.customer_id, { at: now, contact }); + known.set(customerId, { at: now, contact }); return contact; } catch (error) { - console.warn(`Reading Stripe customer ${account.customer_id} failed`, error); + console.warn(`Reading Stripe customer ${customerId} failed`, error); return null; } } @@ -102,7 +94,7 @@ export async function withdrawalOffer( subscription: Held | null, { cached = false } = {}, ): Promise { - if (!subscription?.started_at || !CURRENT_STATUSES.includes(subscription.status)) return null; + if (!subscription?.started_at || !SUBSCRIBED.includes(subscription.status)) return null; const startedAt = new Date(subscription.started_at); const until = new Date(startedAt.getTime() + WITHDRAWAL_OPEN_DAYS * DAY_MS); if (billing.time.now() >= until) return null; @@ -213,24 +205,12 @@ export async function refundPaidInvoices( /** * Ends withdrawal `row`'s subscription at once, without proration or a final - * invoice, then refunds in full every payment its paid invoices received: - * no use deduction, tax included. Each step is safe to repeat. + * invoice (`billing.end`, which does nothing to one already ended), then + * refunds in full every payment its paid invoices received: no use + * deduction, tax included. Each step is safe to repeat. */ async function settle(billing: Billing, row: WithdrawalRow) { - await billing.reconcile(row.userId); - const synced = await billing.db - .selectFrom("subscriptions") - .select("status") - .where("id", "=", row.subscriptionId) - .executeTakeFirst(); - if (synced && CURRENT_STATUSES.includes(synced.status)) { - await billing.stripe.subscriptions.cancel(row.subscriptionId, { - cancellation_details: { comment: ENDED_FOR_WITHDRAWAL }, - prorate: false, - invoice_now: false, - }); - await billing.reconcile(row.userId); - } + await billing.end(row.userId, row.subscriptionId, { reason: END_REASONS.withdrawal }); await refundPaidInvoices(billing, row.subscriptionId, { kind: "withdrawal", id: row.id }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e365fa784..2b904e5dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,11 +339,11 @@ importers: specifier: ^2.0.10 version: 2.1.5(hono@4.13.14) '@pgstencil/auth': - specifier: ^0.4.1 - version: 0.4.1(hono@4.13.14)(kysely@0.29.7)(pg@8.23.1)(pgstencil@0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5) + specifier: ^0.4.2 + version: 0.4.2(hono@4.13.14)(kysely@0.29.7)(pg@8.23.1)(pgstencil@0.4.2(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5) '@pgstencil/stripe': - specifier: ^0.4.1 - version: 0.4.1(kysely@0.29.7)(pgstencil@0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(stripe@22.6.2(@types/node@24.19.2)) + specifier: ^0.4.2 + version: 0.4.2(kysely@0.29.7)(pgstencil@0.4.2(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(stripe@22.6.2(@types/node@24.19.2)) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -357,8 +357,8 @@ importers: specifier: ^0.29.5 version: 0.29.7 pgstencil: - specifier: ^0.4.1 - version: 0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4) + specifier: ^0.4.2 + version: 0.4.2(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4) react: specifier: ^19.2.6 version: 19.3.1 @@ -2994,20 +2994,20 @@ packages: cpu: [x64] os: [win32] - '@pgstencil/auth@0.4.1': - resolution: {integrity: sha512-57R1AeyF6OE6OiJjWt37KFb8LYoVYcWlHKQS1u0mnuSU1BEIptmnZpYb6rfXW5CtbGzXmyxo7u4Q8z9LkP/hDA==} + '@pgstencil/auth@0.4.2': + resolution: {integrity: sha512-3aklWkS/o7oA5Dx8P+KGLENOxg8FdQLgGA6lZK/Gx4yD7uNMFnBVPGMvwMB+J18ih2SUKi30fyQor9PVXL8rnQ==} engines: {node: '>=24'} peerDependencies: hono: ^4.13.7 kysely: ^0.29.5 - pgstencil: ^0.4.1 + pgstencil: ^0.4.2 - '@pgstencil/stripe@0.4.1': - resolution: {integrity: sha512-f8rHAd+Jnnkmjmo5TBg9qabY4nEhczZSndYxpRGlNlT1mw5CSDdNb7aXqG7aJ+VRNUoz8FtR8a3MUfGHRboM5g==} + '@pgstencil/stripe@0.4.2': + resolution: {integrity: sha512-fVRDbo+LDopW1CPQZK9cl+aWdmcqIFx3cEpI3RCT6LzXxwCzazQuNwN/AK8zWPAF17uyWSwfOu0x4jGGRyTbyA==} engines: {node: '>=24'} peerDependencies: kysely: ^0.29.5 - pgstencil: ^0.4.1 + pgstencil: ^0.4.2 stripe: ^22.6.1 '@phosphor-icons/react@2.1.10': @@ -6739,8 +6739,8 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - pgstencil@0.4.1: - resolution: {integrity: sha512-fQJ1p4VFONFPOC8myjHJiEus0GUpH3uBFvteaoIaO6ko6pHuEP4rJBESq+e8fxV4XUXJuRecVJzpJ2AoKCvTRw==} + pgstencil@0.4.2: + resolution: {integrity: sha512-/gOTQx6lggXcl3/jBAuD8ixFICbXFFbqvufybRsod5eeN40nlH4fQVaspeo25e5MhNkFEO3fyVxvj2Y+dfIBHQ==} engines: {node: '>=24'} peerDependencies: cheerio: ^1.2.0 @@ -9975,14 +9975,14 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.21.2': optional: true - '@pgstencil/auth@0.4.1(hono@4.13.14)(kysely@0.29.7)(pg@8.23.1)(pgstencil@0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5)': + '@pgstencil/auth@0.4.2(hono@4.13.14)(kysely@0.29.7)(pg@8.23.1)(pgstencil@0.4.2(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5)': dependencies: better-auth: 1.7.12(pg@8.23.1)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5) hono: 4.13.14 jose: 6.2.13 kysely: 0.29.7 openid-client: 6.8.8 - pgstencil: 0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4) + pgstencil: 0.4.2(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4) transitivePeerDependencies: - '@lynx-js/react' - '@opentelemetry/api' @@ -10004,10 +10004,10 @@ snapshots: - vitest - vue - '@pgstencil/stripe@0.4.1(kysely@0.29.7)(pgstencil@0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(stripe@22.6.2(@types/node@24.19.2))': + '@pgstencil/stripe@0.4.2(kysely@0.29.7)(pgstencil@0.4.2(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(stripe@22.6.2(@types/node@24.19.2))': dependencies: kysely: 0.29.7 - pgstencil: 0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4) + pgstencil: 0.4.2(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4) stripe: 22.6.2(@types/node@24.19.2) '@phosphor-icons/react@2.1.10(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': @@ -13772,7 +13772,7 @@ snapshots: dependencies: split2: 4.2.0 - pgstencil@0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4): + pgstencil@0.4.2(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4): dependencies: kysely: 0.29.7 pg: 8.23.1 diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index bb27fff03..4e62254f6 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -4098,7 +4098,7 @@ "author": "DiffPlug", "homepage": "https://github.com/diffplug/pgstencil#readme", "versions": [ - "0.4.1" + "0.4.2" ] }, { @@ -4107,7 +4107,7 @@ "author": "DiffPlug", "homepage": "https://github.com/diffplug/pgstencil#readme", "versions": [ - "0.4.1" + "0.4.2" ] }, { @@ -4269,7 +4269,7 @@ "author": "DiffPlug", "homepage": "https://github.com/diffplug/pgstencil#readme", "versions": [ - "0.4.1" + "0.4.2" ] }, {