Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions BILLING.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion examples/login/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/stripe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions packages/stripe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
93 changes: 92 additions & 1 deletion packages/stripe/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export type WebhookApply = (
trx: Transaction<BillingDB>,
context: WebhookContext,
) => Promise<void>;
/** 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. */
Expand Down Expand Up @@ -458,7 +466,8 @@ export class Billing<P extends string = string> {
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 } }
: {}),
Expand Down Expand Up @@ -849,6 +858,86 @@ export class Billing<P extends string = string> {
);
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<ReconciledChange | null> {
const rows = (trx: Transaction<BillingDB>) =>
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
Expand Down Expand Up @@ -972,6 +1061,8 @@ export class Billing<P extends string = string> {
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(),
Expand Down
61 changes: 56 additions & 5 deletions packages/stripe/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -206,28 +218,58 @@ 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:
| 'renew'
| '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);
}
Expand Down Expand Up @@ -327,6 +369,14 @@ export async function createStripeDev(
result as Record<string, unknown>,
);
} 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,
Expand All @@ -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')),
Expand Down
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading