From 32dde2262655554fb10a6cc13d7ac6acb5e4c55d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 10 Nov 2026 08:40:09 -0800 Subject: [PATCH 1/5] Move @pgstencil/stripe to stripe 23 (API 2026-10-28.endive) The peer and dev ranges in packages/stripe and the login example's dependency go from ^22.6.1 to ^23.1.0, the newest stable release (published 2026-10-29, outside the one-day minimum release age). The peer range admits v23 only: STRIPE_API_VERSION is the installed SDK's pinned version, so a range spanning both majors would let a consumer's install choose between two API versions and two type surfaces. Stripe 23 removes `payment_method_types` from Checkout session creation; Checkout now sends `allowed_payment_method_types: ['card']`, which filters the account's dynamically eligible methods down to cards. The request snapshot and the trial test's expected parameters follow. Validated: pnpm typecheck, pnpm test:unit, and the billing, billing-http and billing-workers integration tests (54 passing), all against StripeDev. Co-Authored-By: Claude Opus 5.5 --- examples/login/package.json | 2 +- packages/stripe/package.json | 4 ++-- packages/stripe/src/index.ts | 3 ++- pnpm-lock.yaml | 18 +++++++++--------- tests/integration/billing.test.ts | 2 +- .../snapshots/billing/stripe-requests.json | 2 +- 6 files changed, 16 insertions(+), 15 deletions(-) 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/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..32abeda 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -458,7 +458,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 } } : {}), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d25d327..0e359da 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..70492cd 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); 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", From 1d1b851eea03c2404981599a9cfe8bd52acffb94 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 10 Nov 2026 08:43:22 -0800 Subject: [PATCH 2/5] StripeDev: v23 subscription and Checkout shapes, pause and resume Subscriptions StripeDev creates now carry the fields the endive API versions define and that were missing or new: each item's `current_period_start` (advanced with `current_period_end` on renewal and failed payment) and `current_trial: null`, `trial_settings` with `end_behavior.billing_cycle_anchor: null` and the requested `missing_payment_method`, and `pending_update: null`. `transition(id, 'pause')` and `'resume'` model the new pause endpoint and the portal's `subscription_pause` flow: pausing sets `status: 'paused'` with `status_details.paused`, resuming returns to `active` with a new period starting now; each emits `customer.subscription.updated` and then `.paused` or `.resumed`. Checkout session creation refuses `payment_method_types`, which the endive versions removed, and records `allowed_payment_method_types` on the session. A new billing test pins what a paused subscription means to pgstencil: no access, another checkout refused, not chosen by reconcileStale, and access back after resuming. Removing 'paused' from SUBSCRIBED fails it. Validated: pnpm typecheck, pnpm format:check, pnpm test:unit and the three billing integration suites against StripeDev. Co-Authored-By: Claude Opus 5.5 --- packages/stripe/src/testing.ts | 61 ++++++++++++++++++++++++++++--- tests/integration/billing.test.ts | 38 +++++++++++++++++++ tests/unit/stripe-dev.test.ts | 36 ++++++++++++++++++ 3 files changed, 130 insertions(+), 5 deletions(-) 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/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index 70492cd..7447fa7 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -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 }) => { 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(); + } +}); From 6a0f80a5b6fc0e6d7dcb66f748d65cccf01cba34 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 10 Nov 2026 08:43:37 -0800 Subject: [PATCH 3/5] Pin the webhook timestamp tolerance across the stripe 23 default change Stripe 23 makes `webhooks.signature.verifyHeader[Async]` apply the 300-second default when no tolerance is passed (it used to skip the check) and lets 0 really skip it. `verifyWebhook` passes 300 to `constructEventAsync` explicitly, with the injected clock as `receivedAt`, so its behavior is unchanged: v22 and v23 both check the `t=` of the Stripe-Signature header, the time Stripe signed that delivery attempt, against the clock, rejecting only an age over 300 s. A new test pins that: a delivery is accepted exactly 300 s after it was signed and refused one second later; the same event re-signed five hours later, as a Stripe retry is, verifies and is a ledger no-op (one row, one attempt). The test fails with the tolerance set to 299 or to 0. Validated: pnpm typecheck, pnpm format:check, the billing integration suite (28 passing) against StripeDev. Co-Authored-By: Claude Opus 5.5 --- packages/stripe/src/index.ts | 2 ++ tests/integration/billing.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 32abeda..cf1f0f6 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -973,6 +973,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/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index 7447fa7..f5d66d1 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -428,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 }) => { From af904c183dd4ef967c746e05642f18e6ceea64c7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 10 Nov 2026 08:49:04 -0800 Subject: [PATCH 4/5] Docs: stripe 23, endpoint creation, and moving between API versions BILLING.md names the pinned version (2026-10-28.endive for stripe 23.1) and how to create a webhook endpoint at it, explains that the 300-second tolerance reads each delivery's signed timestamp, says what `paused` means for access now that Stripe's pause endpoint and the portal's subscription_pause flow reach it, states which stale owners reconcileStale never chooses, documents the StripeDev pause and resume transitions and what StripeDev does not model, and adds "Moving to a new Stripe API version", marked as not yet exercised end to end. Checkout's card restriction is now `allowed_payment_method_types`. packages/stripe/README.md and PACKAGES.md tell applications that every `stripe` minor release moves the API version webhooks must match, so they pin it with `~`. Validated: pnpm check (format, typecheck, 31 script tests, 208 Vitest tests) passes. Co-Authored-By: Claude Opus 5.5 --- BILLING.md | 24 +++++++++++++++++++----- PACKAGES.md | 2 +- packages/stripe/README.md | 4 +++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/BILLING.md b/BILLING.md index 4cbe336..319db42 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,7 @@ 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. ## Plans and prices @@ -89,7 +89,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 +99,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 +109,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, to repair anything a refused or abandoned delivery left behind. pgstencil has no method for all owners: call `reconcile(owner)` for each of the application's billing owners, which costs one `subscriptions.list` request and one short transaction holding that owner's lock per owner, paced to stay within Stripe's rate limits. `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/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. From ee83505cf5f7a44be01923941e56cfa464036088 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 10 Nov 2026 12:17:43 -0800 Subject: [PATCH 5/5] Billing: reconcileAll reconciles every owner with a customer and reports what changed reconcileStale finds only owners holding a current subscription without access, so an event missed during a Stripe API version move (a cancellation, a portal-scheduled cancellation, a refund) is never repaired by it. reconcileAll keyset-pages every account with a customer, resyncs each under its owner lock, and returns the owners whose subscription rows changed with the rows before and after, plus each failure, without stopping. A dry run rolls each owner's transaction back, so it reports what would change and writes nothing. Its report is the measurement a cut-over needs: each changed owner is one event that was lost. BILLING.md's migration step 5 now names it. Co-Authored-By: Claude Opus 5.5 --- BILLING.md | 4 +- packages/stripe/src/index.ts | 88 +++++++++++++++++++++++++++++++ tests/integration/billing.test.ts | 69 ++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/BILLING.md b/BILLING.md index 319db42..5093769 100644 --- a/BILLING.md +++ b/BILLING.md @@ -48,6 +48,8 @@ A filter on `owner_id` reaches the subscriptions index through the view. The vie `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 `prices` maps each plan name (1–64 letters, digits, `_` or `-`) to one Stripe Price ID, or to several: @@ -119,7 +121,7 @@ Customer and Checkout creation save their idempotency keys before calling Stripe 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, to repair anything a refused or abandoned delivery left behind. pgstencil has no method for all owners: call `reconcile(owner)` for each of the application's billing owners, which costs one `subscriptions.list` request and one short transaction holding that owner's lock per owner, paced to stay within Stripe's rate limits. `reconcileStale` alone does not cover it (see [Access in SQL](#access-in-sql)). +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. diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index cf1f0f6..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. */ @@ -850,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 diff --git a/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index f5d66d1..919f8e5 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -1113,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 }) => {