diff --git a/BILLING.md b/BILLING.md index 4cbe336..5093769 100644 --- a/BILLING.md +++ b/BILLING.md @@ -30,7 +30,7 @@ The HTTP adapter supplies the authorized billing-owner ID. An owner can be a use `checkout(owner, email, plan, { ref })` takes an optional attribution label, such as the link that led to the purchase: 1–40 lowercase letters, digits, `:` or `-`, else `BillingError` 400 before any write. It is stored on the operation (migration 006) and sent as `pgstencil_ref` in the Checkout Session's and subscription's metadata; pgstencil writes that key itself, so a ref cannot replace `pgstencil_owner` or `pgstencil_operation`. An open Checkout keeps the ref it was opened with, and its retries resend it, so a later click's ref never changes its idempotent parameters; a checkout without a ref sends exactly the parameters it did before. `completedCheckout(event, trx)` reads a verified `checkout.session.completed` event back as `{ operation, ownerId, plan, priceId, ref }` from the stored operation, and returns null for any other event or a Checkout pgstencil did not open. Call it inside `webhook`'s `apply` with its transaction to count a completion exactly once, never on redelivery. -Access requires exactly one current subscription — a status in the exported `SUBSCRIBED` list (`trialing`, `active`, `past_due`, `unpaid`, `paused`, `incomplete`) — and that it is `trialing` before its trial end or `active` before its period end. Other statuses deny access. There is no implicit payment-failure grace period. Cancellation scheduled for period end retains access until that deadline. Trial eligibility is consumed once a subscription is observed and never resets when it is canceled. This is per billing owner, not a guarantee against a person creating multiple accounts. +Access requires exactly one current subscription — a status in the exported `SUBSCRIBED` list (`trialing`, `active`, `past_due`, `unpaid`, `paused`, `incomplete`) — and that it is `trialing` before its trial end or `active` before its period end. Other statuses deny access. A `paused` subscription, which Stripe's pause endpoint, a customer portal configured with `subscription_pause`, or a trial ending without a payment method produces, therefore grants nothing yet still blocks another checkout; resuming it starts a new period, and its webhook restores access. There is no implicit payment-failure grace period. Cancellation scheduled for period end retains access until that deadline. Trial eligibility is consumed once a subscription is observed and never resets when it is canceled. This is per billing owner, not a guarantee against a person creating multiple accounts. ## Access in SQL @@ -46,7 +46,9 @@ SELECT EXISTS ( A filter on `owner_id` reaches the subscriptions index through the view. The view runs with its owner's privileges, so a read-only role needs only `GRANT USAGE ON SCHEMA pgstencil_billing` and `GRANT SELECT ON pgstencil_billing.owner_access`, and no grant on the tables beneath it. Grant it after migrating, since the schema is the package's. -`reconcileStale({ limit })` repairs missed renewals from a scheduled job: it reconciles up to `limit` (1–1000) owners, in a random order, that hold an `active` or `trialing` subscription yet no access by `owner_access` at the injected clock — a renewal or trial conversion whose webhook never arrived, or more than one current subscription. Every chosen owner is tried; it returns `{ reconciled, failed }`, the owner IDs reconciled and each failure as `{ ownerId, error }`. An owner Stripe has not moved on yet stays stale and is chosen again on a later run. +`reconcileStale({ limit })` repairs missed renewals from a scheduled job: it reconciles up to `limit` (1–1000) owners, in a random order, that hold an `active` or `trialing` subscription yet no access by `owner_access` at the injected clock — a renewal or trial conversion whose webhook never arrived, or more than one current subscription. Every chosen owner is tried; it returns `{ reconciled, failed }`, the owner IDs reconciled and each failure as `{ ownerId, error }`. An owner Stripe has not moved on yet stays stale and is chosen again on a later run. It never chooses an owner whose row is `paused`, `past_due` or `unpaid`, so a missed resumption or recovered payment waits for that owner's next event or `reconcile(owner)`; nor one whose row still grants access, so a missed cancellation keeps access until the period end, when the owner becomes stale. + +`reconcileAll({ dryRun, pageSize })` reconciles every owner holding a Stripe customer, one at a time in owner ID order, reading `pageSize` (1–1000, default 100) owners per query, for a one-off pass such as after [moving the API version](#moving-to-a-new-stripe-api-version). Each owner costs one `subscriptions.list` request, sequentially, and one short transaction holding that owner's lock. It returns `{ checked, changed, failed }`: how many owners it tried, each owner whose subscription rows changed as `{ ownerId, before, after }` (the rows ordered by ID, compared without `updated_at`), and each failure as `{ ownerId, error }`, without stopping. Every changed owner is an event the webhook never applied. `dryRun: true` reconciles inside a transaction it rolls back, so it reports what would change and writes nothing. ## Plans and prices @@ -89,7 +91,7 @@ pgstencil reads subscriptions, not invoices, charges or refunds. A refund alone ### Managed Payments -`managedPayments: true` sets `managed_payments: { enabled: true }` on every Checkout Session, making Stripe the merchant of record; the installed Stripe SDK types that parameter at `STRIPE_API_VERSION`. Checkout still sends `payment_method_collection: 'always'` and `payment_method_types: ['card']`, and the trial parameters when a trial applies. StripeDev records the parameter but does not model Managed Payments, so confirm in a Stripe sandbox that your account accepts that combination before going live. Changing the flag while a Checkout creation is still retryable changes its idempotent parameters, so Stripe rejects that retry. +`managedPayments: true` sets `managed_payments: { enabled: true }` on every Checkout Session, making Stripe the merchant of record; the installed Stripe SDK types that parameter at `STRIPE_API_VERSION`. Checkout still sends `payment_method_collection: 'always'` and `allowed_payment_method_types: ['card']`, and the trial parameters when a trial applies. StripeDev records the parameter but does not model Managed Payments, so confirm in a Stripe sandbox that your account accepts that combination before going live. Changing the flag while a Checkout creation is still retryable changes its idempotent parameters, so Stripe rejects that retry. ### Stripe Tax @@ -99,7 +101,7 @@ pgstencil reads subscriptions, not invoices, charges or refunds. A refund alone Create the product and one recurring Price per configured Price ID in the appropriate Stripe account. Configure the customer portal for payment-method updates, invoices and cancellation at period end. If you enable plan switching in the portal, explicitly choose and test its proration/effective-date behavior in your Stripe sandbox; the package does not silently choose that policy for you. Restrict the portal to the application's allowlisted prices. -Configure a webhook endpoint with the exact `STRIPE_API_VERSION` exported by the installed SDK. Send `customer.subscription.*`, `checkout.session.*`, and `invoice.*` events to your adapter, and `charge.dispute.*` when the application acts on disputes. The example endpoint is `POST /webhooks/stripe`. Preserve the raw body, forward `Stripe-Signature`, enforce a body limit, and return 2xx only after `billing.webhook()` completes. The SDK checks signatures and the five-minute timestamp tolerance; the service also rejects unexpected API versions, test/live modes and Connect account events. Stripe Connect is outside this package's scope. +Configure a webhook endpoint with the exact `STRIPE_API_VERSION` exported by the installed SDK: `2026-10-28.endive` for `stripe` 23.1. In the Dashboard choose that API version when adding the endpoint, or create it with `stripe.webhookEndpoints.create({ url, enabled_events, api_version: STRIPE_API_VERSION })`. Stripe cannot change an existing endpoint's version, so another version means another endpoint, with its own signing secret; see [Moving to a new Stripe API version](#moving-to-a-new-stripe-api-version). Send `customer.subscription.*`, `checkout.session.*`, and `invoice.*` events to your adapter, and `charge.dispute.*` when the application acts on disputes. The example endpoint is `POST /webhooks/stripe`. Preserve the raw body, forward `Stripe-Signature`, enforce a body limit, and return 2xx only after `billing.webhook()` completes. The SDK checks signatures and a 300-second tolerance between the delivery's signed timestamp and the injected clock. Stripe signs each delivery attempt when it sends it, so a retry hours later verifies, and the event ledger makes it a no-op once processed; a captured delivery replayed later does not verify; the service also rejects unexpected API versions, test/live modes and Connect account events. Stripe Connect is outside this package's scope. An application that reads an event before processing it calls `verifyWebhook(body, signature)` once and passes the returned event to `webhook(event, apply)`; `webhook` accepts only an event that `verifyWebhook` on the same `Billing` returned, never a parsed body. @@ -109,8 +111,22 @@ Customer and Checkout creation save their idempotency keys before calling Stripe `webhook(body, signature, apply)` optionally joins application database fulfillment to the same event transaction. The callback receives the verified event, the Kysely transaction, and a context read after the event's resync: `ownerId`, the billing owner the event names, else null — through its `customer`, or for a `charge.dispute.*` event, which names only its charge, through that charge's customer, retrieved from Stripe inside the transaction — and for a `customer.subscription.*` event `subscription`, that subscription's synchronized row with its `end_reason`, else null. Use explicit application schemas. A failed callback rolls back both billing and application writes, and remains retryable. Keep external side effects out of this callback or persist an outbox record instead. StripeDev also supports one-time payment completion for consumer purchase tests. +## Moving to a new Stripe API version + +> Not yet exercised end to end. These steps follow from the code and the SDK; no deployment has run them against Stripe. + +`STRIPE_API_VERSION` is the pinned version of the `stripe` SDK the application installs, and every SDK minor release moves it: 23.0 pins `2026-09-30.endive`, 23.1 `2026-10-28.endive`. `verifyWebhook` refuses an event at any other version, so any SDK update that changes `STRIPE_API_VERSION` is a cut-over, not a routine bump. Pin `stripe` with `~` or an exact version so it changes only through these steps: + +1. Read the SDK changelog and Stripe's API changelog for every version in between, then run the application's tests against StripeDev with the new SDK. StripeDev always signs events at the installed SDK's version, so it cannot show an endpoint mismatch. +2. Before the first instance of the new release starts, create a second webhook endpoint at the new version, for the same events, preferably at a URL path only the new release serves. Give the new release that endpoint's signing secret as `webhookSecret`. +3. Deploy. Each event created from now on reaches both endpoints with the same event ID. An instance verifies only its own endpoint's deliveries, at its own version, and answers 400 to the other's, which Stripe retries. The `events` ledger applies each event once, whichever copy lands first, and `apply` sees the payload at the version of the release that applied it. +4. An event created before step 2 reaches only the old endpoint, so let the old endpoint drain its pending deliveries before the last old instance stops; creating the new endpoint well before the deploy leaves little to drain. After that, disable the old endpoint: its remaining deliveries, which new instances refuse, duplicate the new endpoint's. +5. Reconcile every billing owner once with `reconcileAll()`, to repair anything a refused or abandoned delivery left behind; each owner it reports changed is an event that was lost. `reconcileStale` alone does not cover it (see [Access in SQL](#access-in-sql)). + +A Checkout creation whose response was lost just before the deploy is retried with the new release's parameters under its saved idempotency key, which Stripe refuses when the parameters changed (moving to stripe 23 replaced `payment_method_types` with `allowed_payment_method_types`); repair that owner as for any ambiguous creation, above. + ## Testing `pnpm test tests/integration/billing.test.ts tests/integration/billing-http.test.ts` uses real Postgres, the real Stripe SDK, and a local HTTP simulator. It covers required-card trial enrollment, exact expiry, paid renewal, failed payment/recovery, cancellation, no repeated trial, monthly/yearly price selection, a founding ladder with no trial, retired Prices, plan-name validation, purchase counts, concurrent creation, lost responses, expired Checkout, owner authorization, CSRF, signatures, duplicates, ordering and webhook retries. `tests/integration/billing-workers.test.ts` runs checkout, Web Crypto webhook verification, status and purchase counts inside workerd. Snapshots cover database state, outgoing Stripe requests, response headers, HTML and Markdown; existing auth tests cover deterministic cookies and email. -`createStripeDev(time, random, statePath?, { recurring })` provides `prices` (`price_dev_monthly` and `price_dev_yearly`); `recurring` adds Price IDs with a `'month'` or `'year'` interval, and subscription Checkout rejects any other Price. It also provides `completeCheckout`, `transition`, `dispute(customer, status?)` (a charge and its `charge.dispute.created` event), `signed`, `deliver`, and `failNext`. Advancing DevTime alone does not run Stripe jobs: explicitly trigger the relevant transition, then deliver its signed event. The `cancel` transition, like the SDK's `subscriptions.cancel` that `end` calls, ends a subscription immediately and emits `customer.subscription.deleted`. The hosted Checkout and portal UI in production, real card validation, Stripe's own trial jobs, taxes and portal configuration still require an opt-in Stripe sandbox smoke test with real test credentials. No such credentials are required by the ordinary suite, and no live billing has been changed. +`createStripeDev(time, random, statePath?, { recurring })` provides `prices` (`price_dev_monthly` and `price_dev_yearly`); `recurring` adds Price IDs with a `'month'` or `'year'` interval, and subscription Checkout rejects any other Price. It also provides `completeCheckout`, `transition` (`renew`, `payment-failed`, `cancel`, `cancel-at-period-end`, `portal-cancel`, `pause`, `resume`), `dispute(customer, status?)` (a charge and its `charge.dispute.created` event), `signed`, `deliver`, and `failNext`. Advancing DevTime alone does not run Stripe jobs: explicitly trigger the relevant transition, then deliver its signed event. The `cancel` transition, like the SDK's `subscriptions.cancel` that `end` calls, ends a subscription immediately and emits `customer.subscription.deleted`; `pause` sets `status: 'paused'` with `status_details.paused` (reason `pause_requested`) and `resume` makes it `active` with a new period starting now, each emitting `customer.subscription.updated` and then `.paused` or `.resumed`. StripeDev's objects carry the fields pgstencil reads and the fields the endive API versions added to them, not Stripe's full objects: no invoices, payments, refunds, `previous_attributes`, or pause endpoint, and Checkout creation refuses `payment_method_types` as the endive versions do. The hosted Checkout and portal UI in production, real card validation, Stripe's own trial jobs, taxes and portal configuration still require an opt-in Stripe sandbox smoke test with real test credentials. No such credentials are required by the ordinary suite, and no live billing has been changed. diff --git a/PACKAGES.md b/PACKAGES.md index 621a02d..5561b98 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -12,7 +12,7 @@ Every archive carries `package/dist/provenance.json`, holding the 40-character ` ## Dependencies -Applications declare pgstencil's peer dependencies themselves: `kysely` for every package, `hono` for `@pgstencil/auth`, and `stripe` for `@pgstencil/stripe`. Auth and billing also peer on the `pgstencil` released with them. A library is a peer when the application and pgstencil must share one copy. Either objects cross the boundary, or the library holds module-level state. Kysely and Hono classes have private fields, so two copies are incompatible types. `pgstencil/diagnostics` keeps its request scope in `AsyncLocalStorage`. The application's Renovate updates each shared library once, and pgstencil uses that copy. pnpm reports a release outside a peer range; pgstencil must widen the range first. +Applications declare pgstencil's peer dependencies themselves: `kysely` for every package, `hono` for `@pgstencil/auth`, and `stripe` for `@pgstencil/stripe`. Auth and billing also peer on the `pgstencil` released with them. A library is a peer when the application and pgstencil must share one copy. Either objects cross the boundary, or the library holds module-level state. Kysely and Hono classes have private fields, so two copies are incompatible types. `pgstencil/diagnostics` keeps its request scope in `AsyncLocalStorage`. The application's Renovate updates each shared library once, and pgstencil uses that copy. pnpm reports a release outside a peer range; pgstencil must widen the range first. `stripe` is the exception to taking each update as it comes: every minor release changes the Stripe API version that webhooks must match, so an application pins it with `~` and moves it as [BILLING.md](BILLING.md#moving-to-a-new-stripe-api-version) describes. Every other dependency is private: pgstencil owns its version and applications do not import it. Better Auth is deliberately private. pgstencil imports its internal subpaths and tests login and linking rules against specific releases, so it is pinned to the exact release CI tested. Even a patch can change internals pgstencil reads (1.7.7 renamed its OAuth state rows), so each Better Auth upgrade reaches applications only through a pgstencil release. A new login provider or Better Auth plugin belongs in `@pgstencil/auth`, not in an application. Private ranges start at the version pgstencil's CI tested. [`.github/renovate.json`](.github/renovate.json) raises that floor, and the next release carries it into each application. diff --git a/examples/login/package.json b/examples/login/package.json index 3556c48..8d1cac9 100644 --- a/examples/login/package.json +++ b/examples/login/package.json @@ -10,7 +10,7 @@ "pgstencil": "workspace:*", "@pgstencil/auth": "workspace:*", "hono": "^4.13.7", - "stripe": "^22.6.1" + "stripe": "^23.1.0" }, "devDependencies": { "node-pg-migrate": "^9.0.0", diff --git a/packages/stripe/README.md b/packages/stripe/README.md index 5de5174..0507b56 100644 --- a/packages/stripe/README.md +++ b/packages/stripe/README.md @@ -5,7 +5,9 @@ Stripe SaaS subscriptions with named plans, optional card-required trials, durab > **Built for our own applications.** pgstencil is developed alongside DiffPlug's products and published so they can share it. It is 0.x: a minor release may break compatibility, and there is no support promise. ```sh -pnpm add @pgstencil/stripe pgstencil kysely stripe +pnpm add @pgstencil/stripe pgstencil kysely stripe@~23.1.0 ``` The packages after the first are peer dependencies: your application owns their versions and shares one copy with pgstencil. See [PACKAGES.md](https://github.com/diffplug/pgstencil/blob/main/PACKAGES.md) for usage and the dependency policy, and [SECURITY.md](https://github.com/diffplug/pgstencil/blob/main/SECURITY.md) for the audited security rules. Source: [diffplug/pgstencil](https://github.com/diffplug/pgstencil). + +It peers on `stripe` 23. `STRIPE_API_VERSION`, which webhooks must match, is the installed SDK's pinned API version (`2026-10-28.endive` for 23.1) and moves with every SDK minor release, so pin `stripe` with `~` and follow [Moving to a new Stripe API version](https://github.com/diffplug/pgstencil/blob/main/BILLING.md#moving-to-a-new-stripe-api-version) to change it. diff --git a/packages/stripe/package.json b/packages/stripe/package.json index a5c4160..119a3e8 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -10,12 +10,12 @@ "peerDependencies": { "kysely": "^0.29.5", "pgstencil": "workspace:^", - "stripe": "^22.6.1" + "stripe": "^23.1.0" }, "devDependencies": { "kysely": "^0.29.5", "pgstencil": "workspace:*", - "stripe": "^22.6.1" + "stripe": "^23.1.0" }, "engines": { "node": ">=24" diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 3c8c8f3..18ea74b 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -55,6 +55,14 @@ export type WebhookApply = ( trx: Transaction, context: WebhookContext, ) => Promise; +/** An owner `reconcileAll` changed: its subscription rows before and after, ordered by ID. */ +export interface ReconciledChange { + ownerId: string; + before: BillingSubscription[]; + after: BillingSubscription[]; +} +/** Thrown to roll back a dry run's transaction. */ +class RolledBack extends Error {} /** A completed subscription Checkout, read from its verified completion event. */ export interface CompletedCheckout { /** The operation ID `checkout` returned. */ @@ -458,7 +466,8 @@ export class Billing

{ client_reference_id: ownerId, line_items: [{ price: operation.price_id, quantity: 1 }], payment_method_collection: 'always', - payment_method_types: ['card'], + // Filters the account's dynamically eligible methods down to cards. + allowed_payment_method_types: ['card'], ...(this.config.managedPayments ? { managed_payments: { enabled: true } } : {}), @@ -849,6 +858,86 @@ export class Billing

{ ); return { reconciled, failed }; } + /** + * Reconciles every owner holding a Stripe customer, one at a time in owner + * ID order, reading `pageSize` (1-1000, default 100) owners per query: one + * `subscriptions.list` request each, for a one-off pass such as after a + * Stripe API version move. Returns how many owners it checked, each owner + * whose subscription rows changed, with the rows before and after, and each + * failure, without stopping. `dryRun` reconciles inside a transaction it + * rolls back, so it reports what would change and writes nothing. + */ + async reconcileAll( + options: { dryRun?: boolean; pageSize?: number } = {}, + ): Promise<{ + checked: number; + changed: ReconciledChange[]; + failed: { ownerId: string; error: unknown }[]; + }> { + const { dryRun = false, pageSize = 100 } = options; + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) + throw new Error('pageSize must be an integer between 1 and 1000'); + const changed: ReconciledChange[] = []; + const failed: { ownerId: string; error: unknown }[] = []; + let checked = 0; + let cursor: string | undefined; + for (;;) { + let query = this.db + .selectFrom('accounts') + .select('owner_id') + .where('customer_id', 'is not', null) + .orderBy('owner_id') + .limit(pageSize); + if (cursor !== undefined) query = query.where('owner_id', '>', cursor); + const page = await query.execute(); + for (const { owner_id: ownerId } of page) { + checked++; + await this.reconcileReporting(ownerId, dryRun).then( + (change) => change && changed.push(change), + (error: unknown) => failed.push({ ownerId, error }), + ); + } + if (page.length < pageSize) break; + cursor = page.at(-1)!.owner_id; + } + return { checked, changed, failed }; + } + private async reconcileReporting( + ownerId: string, + dryRun: boolean, + ): Promise { + const rows = (trx: Transaction) => + trx + .selectFrom('subscriptions') + .selectAll() + .where('owner_id', '=', ownerId) + .orderBy('id') + .execute(); + // Every resync rewrites `updated_at`, so it alone is no change. + const comparable = (subscriptions: BillingSubscription[]) => + JSON.stringify(subscriptions.map(({ updated_at: _, ...row }) => row)); + let change: ReconciledChange | null = null; + try { + await this.db.transaction().execute(async (trx) => { + // `before` is read under the owner lock, so no webhook lands between it and the resync. + await trx + .selectFrom('accounts') + .select('owner_id') + .where('owner_id', '=', ownerId) + .forUpdate() + .execute(); + const before = await rows(trx); + await this.synchronize(trx, ownerId); + const after = await rows(trx); + if (comparable(before) !== comparable(after)) + change = { ownerId, before, after }; + if (dryRun) throw new RolledBack(); + }); + } catch (error) { + if (!(error instanceof RolledBack)) throw error; + } + return change; + } async status(ownerId: string) { const [account, subscriptions, entitled] = await Promise.all([ this.db @@ -972,6 +1061,8 @@ export class Billing

{ body, signature, this.config.webhookSecret, + // Seconds between the delivery's signed `t=` (Stripe signs every + // attempt afresh, so retries pass) and the injected clock. 300, Stripe.createSubtleCryptoProvider(), this.time.now().getTime(), diff --git a/packages/stripe/src/testing.ts b/packages/stripe/src/testing.ts index e0dea2b..ce83ca6 100644 --- a/packages/stripe/src/testing.ts +++ b/packages/stripe/src/testing.ts @@ -125,6 +125,16 @@ export async function createStripeDev( ended_at: null, canceled_at: null, trial_end: days ? seconds() + days * 86400 : null, + trial_settings: { + end_behavior: { + billing_cycle_anchor: null, + missing_payment_method: + p.get( + 'subscription_data[trial_settings][end_behavior][missing_payment_method]', + ) ?? 'create_invoice', + }, + }, + pending_update: null, cancel_at: null, cancel_at_period_end: false, metadata: Object.fromEntries( @@ -142,6 +152,8 @@ export async function createStripeDev( id: id('si'), quantity: 1, price: { id: price }, + current_trial: null, + current_period_start: seconds(), current_period_end: days ? seconds() + days * 86400 : periodEnd(price), @@ -206,6 +218,12 @@ export async function createStripeDev( sub.ended_at = seconds(); return event('customer.subscription.deleted', sub); } + /** Starts the item's next billing period now. */ + function newPeriod(sub: Stripe.Subscription) { + const item = sub.items.data[0]!; + item.current_period_start = seconds(); + item.current_period_end = periodEnd(item.price.id); + } function transition( subscriptionId: string, action: @@ -213,21 +231,45 @@ export async function createStripeDev( | 'payment-failed' | 'cancel' | 'cancel-at-period-end' - | 'portal-cancel', + | 'portal-cancel' + | 'pause' + | 'resume', ) { const sub = subscriptions.get(subscriptionId); if (!sub) throw new Error('Subscription not found'); let result: Stripe.Event; if (action === 'cancel') result = cancel(sub); - else { + else if (action === 'pause') { + // As the pause endpoint, or the portal's subscription_pause flow, makes: + // the period stops advancing until the subscription resumes. + if (!['active', 'trialing', 'past_due'].includes(sub.status)) + throw new Error('Only an active subscription can be paused'); + sub.status = 'paused'; + sub.status_details = { + paused: { + type: 'subscription', + subscription: { type: 'pause_requested' }, + transitioned_at: seconds(), + }, + }; + event('customer.subscription.updated', sub); + result = event('customer.subscription.paused', sub); + } else if (action === 'resume') { + // Resuming resets the billing cycle anchor to now, Stripe's default. + if (sub.status !== 'paused') + throw new Error('Subscription is not paused'); + sub.status = 'active'; + delete sub.status_details; + newPeriod(sub); + event('customer.subscription.updated', sub); + result = event('customer.subscription.resumed', sub); + } else { if (action === 'cancel-at-period-end') sub.cancel_at_period_end = true; else if (action === 'portal-cancel') sub.cancel_at = sub.items.data[0]!.current_period_end; else { sub.status = action === 'renew' ? 'active' : 'past_due'; - sub.items.data[0]!.current_period_end = periodEnd( - sub.items.data[0]!.price.id, - ); + newPeriod(sub); } result = event('customer.subscription.updated', sub); } @@ -327,6 +369,14 @@ export async function createStripeDev( result as Record, ); } else if (path === '/v1/checkout/sessions' && req.method === 'POST') { + // Removed from Checkout creation at the endive API versions. + if ( + [...body.keys()].some((key) => key.startsWith('payment_method_types')) + ) + throw new Error('Received unknown parameter: payment_method_types'); + const allowed = [...body] + .filter(([key]) => /^allowed_payment_method_types\[\d+\]$/.test(key)) + .map(([, value]) => value); const sessionId = id('cs'); const session = { id: sessionId, @@ -341,6 +391,7 @@ export async function createStripeDev( .map(([key, value]) => [key.slice(9, -1), value]), ), subscription: null, + allowed_payment_method_types: allowed.length ? allowed : null, payment_status: 'unpaid', livemode: false, expires_at: Number(body.get('expires_at')), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfbfee6..ddd9ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,8 +258,8 @@ importers: specifier: workspace:* version: link:../../packages/pgstencil stripe: - specifier: ^22.6.1 - version: 22.6.2(@types/node@24.19.2) + specifier: ^23.1.0 + version: 23.1.0(@types/node@24.19.2) devDependencies: node-pg-migrate: specifier: ^9.0.0 @@ -336,8 +336,8 @@ importers: specifier: workspace:* version: link:../pgstencil stripe: - specifier: ^22.6.1 - version: 22.6.2(@types/node@24.19.2) + specifier: ^23.1.0 + version: 23.1.0(@types/node@24.19.2) packages: @@ -2543,11 +2543,11 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} - stripe@22.6.2: - resolution: {integrity: sha512-PwRE2scocvXqDKPiskMa5vRJEAEkq46W69XyUYmAwrNSLr675lQnfqLwnsndAWizwgihOWw/irLmoFRo3ez7ew==} - engines: {node: '>=18'} + stripe@23.1.0: + resolution: {integrity: sha512-fVUfMSfTPoukfoV//MUKAx2+YxyKmYaYtnAL9VkfnHfaPNQylFdWZMD+zgUFerI8rjbaiER8Vzj2XEUQ2AhAsQ==} + engines: {node: '>=20'} peerDependencies: - '@types/node': '>=18' + '@types/node': '>=20' peerDependenciesMeta: '@types/node': optional: true @@ -4716,7 +4716,7 @@ snapshots: dependencies: ansi-regex: 6.3.0 - stripe@22.6.2(@types/node@24.19.2): + stripe@23.1.0(@types/node@24.19.2): optionalDependencies: '@types/node': 24.19.2 diff --git a/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index 4794f33..919f8e5 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -178,7 +178,7 @@ billingTest( )!; expect(creation.body).toMatchObject({ payment_method_collection: 'always', - 'payment_method_types[0]': 'card', + 'allowed_payment_method_types[0]': 'card', 'subscription_data[trial_period_days]': '14', }); const sub = f.dev.completeCheckout(session.id); @@ -278,6 +278,44 @@ billingTest( }, ); +billingTest( + 'a paused subscription grants nothing and blocks another checkout; resuming restores access', + async ({ f }) => { + const { session } = await f.start(); + const sub = f.dev.completeCheckout(session.id); + await f.deliver(); + f.time.advanceDays(14); + f.dev.transition(sub.id, 'renew'); + await f.deliver(); + expect((await f.billing.status('alice')).access).toBe(true); + const paused = f.dev.transition(sub.id, 'pause'); + if (paused.type !== 'customer.subscription.paused') + throw new Error(`Unexpected ${paused.type}`); + expect(paused.data.object.status_details?.paused.subscription.type).toBe( + 'pause_requested', + ); + await f.deliver(); + const status = await f.billing.status('alice'); + expect([status.access, status.subscription?.status]).toEqual([ + false, + 'paused', + ]); + await expect(f.start()).rejects.toThrow('Manage billing'); + // Paused is not stale: it grants nothing until Stripe resumes it. + expect((await f.billing.reconcileStale({ limit: 10 })).reconciled).toEqual( + [], + ); + f.time.advanceDays(10); + f.dev.transition(sub.id, 'resume'); + await f.deliver(); + expect(await f.billing.status('alice')).toMatchObject({ + access: true, + // Resuming starts a new period at once. + accessUntil: new Date('2020-02-25T00:00:00Z'), + }); + }, +); + billingTest( 'lost creation responses and concurrent clicks reuse durable idempotency keys', async ({ f }) => { @@ -390,6 +428,36 @@ billingTest( }, ); +billingTest( + 'the five-minute tolerance reads the delivery signature: a retry signed hours later applies once, an old signature never', + async ({ f }) => { + const { session } = await f.start(); + f.dev.completeCheckout(session.id); + const event = f.dev.events[0]!; + const first = f.dev.signed(event); + // Accepted at the tolerance's edge, then refused one second past it. + f.time.advanceMilliseconds(300_000); + await f.billing.webhook(first.body, first.signature); + f.time.advanceMilliseconds(1000); + await expect( + f.billing.webhook(first.body, first.signature), + ).rejects.toThrow('Timestamp'); + // Stripe signs each delivery attempt when it sends it, so a retry of an + // hours-old event carries a fresh timestamp; the ledger makes it a no-op. + f.time.advanceHours(5); + const retry = f.dev.signed(event); + await f.billing.webhook(retry.body, retry.signature); + const ledger = await f.billing.db + .selectFrom('events') + .select(['id', 'attempts', 'failed']) + .execute(); + expect(ledger.map((e) => [e.id, e.attempts, e.failed])).toEqual([ + [event.id, 1, false], + ]); + expect((await f.billing.status('alice')).access).toBe(true); + }, +); + billingTest( 'expired and canceled checkouts can be replaced without spending the trial', async ({ f }) => { @@ -1045,6 +1113,75 @@ billingTest( }, ); +billingTest( + 'reconcileAll walks every owner with a customer and reports exactly the owners a missed event left stale', + async ({ f }) => { + const subs: Record = {}; + for (const owner of ['alice', 'bob', 'carol', 'dave']) { + const { session } = await f.start('monthly', owner); + subs[owner] = f.dev.completeCheckout(session.id).id; + } + await f.deliver(); + // An account without a customer has nothing in Stripe to compare. + await f.billing.account('erin', 'erin@example.test'); + // Stripe ends alice's subscription and bob schedules his cancellation in + // the portal, but neither webhook arrives. + f.dev.transition(subs.alice!, 'cancel'); + f.dev.transition(subs.bob!, 'portal-cancel'); + f.dev.events.length = 0; + const stored = async (owner: string) => { + const { subscription, access } = await f.billing.status(owner); + return `${subscription!.status}:${subscription!.cancel_at_period_end}:${access}`; + }; + const summary = (change: { + before: { status: string; cancel_at_period_end: boolean }[]; + after: { status: string; cancel_at_period_end: boolean }[]; + }) => + [change.before, change.after] + .map((rows) => + rows.map((r) => `${r.status}:${r.cancel_at_period_end}`).join(), + ) + .join(' -> '); + + // A page of one owner walks every page; a dry run writes nothing. + const dry = await f.billing.reconcileAll({ dryRun: true, pageSize: 1 }); + expect(dry.checked).toBe(4); + expect(dry.failed).toEqual([]); + expect(dry.changed.map((c) => c.ownerId)).toEqual(['alice', 'bob']); + expect(dry.changed.map(summary)).toEqual([ + 'trialing:false -> canceled:false', + 'trialing:false -> trialing:true', + ]); + expect(await stored('alice')).toBe('trialing:false:true'); + expect(await stored('bob')).toBe('trialing:false:true'); + + // One owner's failure is collected, and the walk goes on. + f.dev.failNext('/v1/subscriptions'); + const first = await f.billing.reconcileAll({ pageSize: 3 }); + expect(first.checked).toBe(4); + expect(first.failed.map((x) => x.ownerId)).toEqual(['alice']); + expect(first.changed.map((c) => c.ownerId)).toEqual(['bob']); + expect(await stored('bob')).toBe('trialing:true:true'); + expect(await stored('alice')).toBe('trialing:false:true'); + + const second = await f.billing.reconcileAll(); + expect(second.failed).toEqual([]); + expect(second.changed.map((c) => c.ownerId)).toEqual(['alice']); + expect(await stored('alice')).toBe('canceled:false:false'); + expect(await stored('carol')).toBe('trialing:false:true'); + + // Once every row matches Stripe, nothing changes. + expect(await f.billing.reconcileAll()).toEqual({ + checked: 4, + changed: [], + failed: [], + }); + await expect(f.billing.reconcileAll({ pageSize: 0 })).rejects.toThrow( + 'pageSize', + ); + }, +); + foundingTest( 'checkout charges only a Price the application expects, and replace gives way to another plan', async ({ f }) => { diff --git a/tests/integration/snapshots/billing/stripe-requests.json b/tests/integration/snapshots/billing/stripe-requests.json index 995eef8..627756d 100644 --- a/tests/integration/snapshots/billing/stripe-requests.json +++ b/tests/integration/snapshots/billing/stripe-requests.json @@ -10,6 +10,7 @@ }, { "body": { + "allowed_payment_method_types[0]": "card", "cancel_url": "/billing", "client_reference_id": "Va8eN8Q3_3xqMF9Im1yUvg", "customer": "cus_coH1oXmUHUAvwrZs", @@ -19,7 +20,6 @@ "metadata[pgstencil_operation]": "nh6LvhIPrrm-MNZdruKyJF6UKMs6w2sRh2V87wsKWt8", "mode": "subscription", "payment_method_collection": "always", - "payment_method_types[0]": "card", "subscription_data[metadata][pgstencil_operation]": "nh6LvhIPrrm-MNZdruKyJF6UKMs6w2sRh2V87wsKWt8", "subscription_data[metadata][pgstencil_owner]": "Va8eN8Q3_3xqMF9Im1yUvg", "subscription_data[trial_period_days]": "14", diff --git a/tests/unit/stripe-dev.test.ts b/tests/unit/stripe-dev.test.ts index 53c4029..e53d22f 100644 --- a/tests/unit/stripe-dev.test.ts +++ b/tests/unit/stripe-dev.test.ts @@ -41,3 +41,39 @@ test('Checkout returns to the frontend and substitutes the session ID', async () await new Promise((resolve) => webhook.close(() => resolve())); } }); + +test('Checkout refuses payment_method_types, which the endive API versions removed', async () => { + const dev = await createStripeDev( + new DevTime('2020-01-01'), + new DevRandom('payment-methods'), + ); + try { + const create = (methods: string) => + fetch(`${dev.origin}/v1/checkout/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + mode: 'subscription', + 'line_items[0][price]': dev.prices.monthly, + 'line_items[0][quantity]': '1', + [`${methods}[0]`]: 'card', + expires_at: String(Date.parse('2020-01-01T01:00:00Z') / 1000), + success_url: 'https://example.test/billing', + }), + signal: AbortSignal.timeout(5000), + }); + const refused = await create('payment_method_types'); + expect(refused.status).toBe(400); + expect(await refused.text()).toContain( + 'unknown parameter: payment_method_types', + ); + const accepted = await create('allowed_payment_method_types'); + expect(accepted.status).toBe(200); + const session = (await accepted.json()) as { + allowed_payment_method_types: string[] | null; + }; + expect(session.allowed_payment_method_types).toEqual(['card']); + } finally { + await dev.close(); + } +});