@volter/twin-stripe 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/stripe-capabilities.js +14 -0
- package/dist/src/stripe-events.js +3 -3
- package/dist/src/stripe-mirror-ui.js +3 -3
- package/dist/src/stripe-server.js +56 -20
- package/package.json +4 -4
- package/src/stripe-capabilities.ts +16 -0
- package/src/stripe-events.ts +3 -3
- package/src/stripe-mirror-ui.ts +3 -3
- package/src/stripe-server.ts +52 -20
|
@@ -213,6 +213,20 @@ export const STRIPE_CAPABILITIES = [
|
|
|
213
213
|
const l = await h({ m: 'GET', p: '/v1/customers' });
|
|
214
214
|
return ok(g) && id(g) === id(c) && ok(u) && field(u, 'name') === 'Ada Lovelace' && field(l, 'object') === 'list';
|
|
215
215
|
})),
|
|
216
|
+
// stripe-node posts a null metadata value as `metadata[key]=` (Cal.com's PaymentIntent metadata, a guest with no phone)
|
|
217
|
+
done('stripe.metadata.create_empty_value_unset', 'metadata', 'A create\'s metadata key posted empty is not set (an empty value unsets a key); metadata posted empty is {}', 'api', 'common', () => withRoot(async (h) => {
|
|
218
|
+
const c = await h({ m: 'POST', p: '/v1/customers', b: 'email=sam@twin.test&metadata[identifier]=cal.com&metadata[bookerPhoneNumber]=' });
|
|
219
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=5000¤cy=usd&customer=${id(c)}&metadata[bookingId]=12&metadata[calAccountId]=` });
|
|
220
|
+
const none = await h({ m: 'POST', p: '/v1/products', b: 'name=Consultation&metadata=' });
|
|
221
|
+
const got = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
222
|
+
const md = (r) => r.body.metadata;
|
|
223
|
+
return ok(c) && JSON.stringify(md(c)) === JSON.stringify({ identifier: 'cal.com' })
|
|
224
|
+
&& ok(pi) && JSON.stringify(md(pi)) === JSON.stringify({ bookingId: '12' }) && JSON.stringify(md(got)) === JSON.stringify({ bookingId: '12' })
|
|
225
|
+
&& ok(none) && JSON.stringify(md(none)) === '{}';
|
|
226
|
+
})),
|
|
227
|
+
// Objects on a connected account are that account's: Stripe answers a retrieve of one without its Stripe-Account
|
|
228
|
+
// header (or with another account's) 404 resource_missing. The twin keeps one store for the platform and its accounts.
|
|
229
|
+
todo('stripe.connect.account_scoped_objects', 'connect', 'Objects made with a Stripe-Account header are visible only as that account (retrieve/list/update from the platform or another account is resource_missing)', 'api', 'common'),
|
|
216
230
|
done('stripe.customers.list_filter', 'customers', 'Customer list filter by email', 'api', 'core', () => withRoot(async (h) => {
|
|
217
231
|
await h({ m: 'POST', p: '/v1/customers', b: 'email=match@twin.test' });
|
|
218
232
|
await h({ m: 'POST', p: '/v1/customers', b: 'email=other@twin.test' });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { nodeBuiltin, worldNow } from '@volter/world-core';
|
|
1
|
+
import { nodeBuiltin, worldNow, deliveryTraceHeaders } from '@volter/world-core';
|
|
2
2
|
import { appDestination, appFetch } from '@volter/world-core/app-route';
|
|
3
3
|
import { worldEgressRefusal } from '@volter/world-core/network-policy';
|
|
4
4
|
import vendorEvents from './generated/events.gen.json' with { type: 'json' };
|
|
@@ -240,7 +240,7 @@ async function postEvent(url, event, payload, secret) {
|
|
|
240
240
|
for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
|
|
241
241
|
try {
|
|
242
242
|
const header = generateTestHeaderString({ payload, secret });
|
|
243
|
-
await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
|
|
243
|
+
await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() }, body: payload });
|
|
244
244
|
return;
|
|
245
245
|
}
|
|
246
246
|
catch (error) {
|
|
@@ -296,7 +296,7 @@ async function httpAuthRequestDelivery(url, event, secret) {
|
|
|
296
296
|
async function postAuthRequest(url, payload, header) {
|
|
297
297
|
const res = await appFetch(url, {
|
|
298
298
|
method: 'POST',
|
|
299
|
-
headers: { 'content-type': 'application/json', 'stripe-signature': header },
|
|
299
|
+
headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() },
|
|
300
300
|
body: payload,
|
|
301
301
|
signal: AbortSignal.timeout(STRIPE_REALTIME_AUTH_TIMEOUT_MS),
|
|
302
302
|
});
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
// twin internals; the client reads and writes only through Stripe's API (`/v1/...`, client/dashboard-api.ts),
|
|
5
5
|
// so it renders a twin or a real account's test data unchanged, pointed at any origin by configuration.
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
|
-
import { bundleClient, fileResponse } from '@volter/world-core';
|
|
7
|
+
import { bundleClient, fileResponse, filePathOf } from '@volter/world-core';
|
|
8
8
|
import { serveHttp } from '@volter/world-core';
|
|
9
9
|
import { createStripeTwinFetch } from "./stripe-server.js";
|
|
10
|
-
const CLIENT_ENTRY = () => new URL('../client/stripe-mirror.tsx', import.meta.url)
|
|
11
|
-
const CLIENT_CSS = () => new URL('../client/stripe-mirror.css', import.meta.url)
|
|
10
|
+
const CLIENT_ENTRY = () => filePathOf(new URL('../client/stripe-mirror.tsx', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
11
|
+
const CLIENT_CSS = () => filePathOf(new URL('../client/stripe-mirror.css', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Pure, dependency-free render/format/resolution helpers.
|
|
14
14
|
//
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// The surface is a plain `fetch` (`createStripeTwinFetch`) and the SERVER is one line of
|
|
8
8
|
// `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
|
|
9
9
|
// port to bind, so it mounts the fetch in-process).
|
|
10
|
-
import { bindSemantics, coreFor, createDerivedFetch, crossCutting, readParams, semanticsContext, serveHttp, vendorError } from '@volter/world-core';
|
|
10
|
+
import { bindSemantics, coreFor, createDerivedFetch, crossCutting, derivedRequestScopes, readParams, runAsVendorMove, runWithRequestTrace, semanticsContext, serveHttp, vendorError } from '@volter/world-core';
|
|
11
11
|
import { stripeCheckoutFlow } from "./screens/checkout.js";
|
|
12
12
|
import { stripeJs } from "./stripe-js.js";
|
|
13
13
|
import { stripeFinancialConnectionsFlow } from "./screens/financial-connections.js";
|
|
@@ -219,26 +219,29 @@ export function createStripeTwinFetch(options) {
|
|
|
219
219
|
// later: semantics/renewals.ts; each account's automatic payouts: semantics/balance.ts) are caught up to the World's clock before anything is answered, so every door
|
|
220
220
|
// (the API, the hosted pages) reads the account as it stands now
|
|
221
221
|
const billingClock = surface.operations.find((o) => o.id === 'GetSubscriptions');
|
|
222
|
+
// time's moves are Stripe's own, not the caller's: they land under a read-only request too (runAsVendorMove)
|
|
222
223
|
const catchUp = async (request) => {
|
|
223
224
|
if (readOnly)
|
|
224
225
|
return;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
226
|
+
await runAsVendorMove(async () => {
|
|
227
|
+
const ctx = await semanticsContext(manifest, new Request(request.url), billingClock, scope);
|
|
228
|
+
// a test clock advanced by the last request has reached its time (semantics/test-clocks.ts)
|
|
229
|
+
await finishClockAdvances(ctx);
|
|
230
|
+
await advanceBilling(ctx);
|
|
231
|
+
// a submitted bank debit settles (in test mode at once) before anything is answered (semantics/payment-intents.ts)
|
|
232
|
+
await settleBankDebits(ctx);
|
|
233
|
+
// a coupon past its redeem_by is no longer valid (semantics/coupons.ts)
|
|
234
|
+
await lapseCoupons(ctx);
|
|
235
|
+
// a top-up's funds arrive five days after it is made (semantics/terminal.ts)
|
|
236
|
+
await settleTopups(ctx);
|
|
237
|
+
// a report run completes (semantics/platform.ts)
|
|
238
|
+
await finishReportRuns(ctx);
|
|
239
|
+
// a real-time authorization request no one answered is decided when its window ends (semantics/issuing.ts)
|
|
240
|
+
await lapseRealtimeRequests(ctx);
|
|
241
|
+
// a refund held for want of balance is made when funds cover it, before any payout takes them (semantics/refunds.ts)
|
|
242
|
+
await settleHeldRefunds(ctx);
|
|
243
|
+
await advancePayouts(ctx);
|
|
244
|
+
});
|
|
242
245
|
};
|
|
243
246
|
// Time's EVENTS, sent on the vendor's clock through a twin-only drain door, as the qstash and vercel twins send
|
|
244
247
|
// theirs (a caller — a runner's drainer — drives each move). POST /_twin/drain catches time up as every request
|
|
@@ -330,7 +333,10 @@ export function createStripeTwinFetch(options) {
|
|
|
330
333
|
const answered = creates ? body : withoutEndpointSecret(body);
|
|
331
334
|
return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, await expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
332
335
|
};
|
|
333
|
-
|
|
336
|
+
// the request's W3C trace context scopes every door (the API, the hosted flows, the drain): their writes record it and
|
|
337
|
+
// the webhooks they cause continue it (world-core trace-context)
|
|
338
|
+
// a read-only request (x-volter-read-only) writes nothing, answered with Stripe's read-only error
|
|
339
|
+
return derivedRequestScopes(manifest, Object.assign((request) => runWithRequestTrace(request, () => rendered(request)), { owners: derived.owners }));
|
|
334
340
|
}
|
|
335
341
|
export async function createStripeTwinServer(options) {
|
|
336
342
|
const server = await serveHttp({
|
|
@@ -340,6 +346,34 @@ export async function createStripeTwinServer(options) {
|
|
|
340
346
|
});
|
|
341
347
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
342
348
|
}
|
|
349
|
+
/** A create's `metadata` holds no key posted empty. "Individual keys can be unset by posting an empty value to them. All
|
|
350
|
+
* keys can be unset by posting an empty value to `metadata`" (every metadata parameter of docs.stripe.com/api): an empty
|
|
351
|
+
* value is the absence of the key, on a create as on an update, so an object never holds a key set to "". Clients post
|
|
352
|
+
* one without meaning to: stripe-node encodes a `null` value as `metadata[key]=` (Cal.com's PaymentIntent metadata
|
|
353
|
+
* carries `bookerPhoneNumber: null` for a guest who gave no phone). The request is rewritten as JSON without them. */
|
|
354
|
+
async function createMetadata(request, path) {
|
|
355
|
+
const op = CREATES.find((c) => c.re.test(path))?.op;
|
|
356
|
+
if (!op)
|
|
357
|
+
return request;
|
|
358
|
+
const params = await readParams(manifest, request.clone(), op);
|
|
359
|
+
if (!('metadata' in params))
|
|
360
|
+
return request;
|
|
361
|
+
const given = params.metadata;
|
|
362
|
+
let metadata;
|
|
363
|
+
if (given === '')
|
|
364
|
+
metadata = {};
|
|
365
|
+
else if (given && typeof given === 'object' && !Array.isArray(given) && Object.values(given).includes('')) {
|
|
366
|
+
metadata = Object.fromEntries(Object.entries(given).filter(([, v]) => v !== ''));
|
|
367
|
+
}
|
|
368
|
+
else
|
|
369
|
+
return request;
|
|
370
|
+
const headers = new Headers(request.headers);
|
|
371
|
+
headers.set('content-type', 'application/json');
|
|
372
|
+
return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...params, metadata }) });
|
|
373
|
+
}
|
|
374
|
+
// the create operations, by their path as a pattern, for createMetadata
|
|
375
|
+
const CREATES = surface.operations.filter((o) => o.class === 'create' && o.method.toUpperCase() === 'POST')
|
|
376
|
+
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
343
377
|
// the update operations, by their path as a pattern, for mergeMetadata
|
|
344
378
|
const UPDATES = surface.operations.filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
|
|
345
379
|
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
@@ -365,7 +399,9 @@ async function mergeMetadata(request, coreMerges, scope) {
|
|
|
365
399
|
break;
|
|
366
400
|
}
|
|
367
401
|
}
|
|
368
|
-
if (!op
|
|
402
|
+
if (!op)
|
|
403
|
+
return createMetadata(request, path);
|
|
404
|
+
if (!op.resource)
|
|
369
405
|
return request;
|
|
370
406
|
const params = await readParams(manifest, request.clone(), op);
|
|
371
407
|
if (!('metadata' in params))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-stripe",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Local Stripe twin — a faithful, stateful local Stripe API your real `stripe` SDK talks to unmodified. Mirror, simulate, and fork. Built on @volter/world-core.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -56,15 +56,15 @@
|
|
|
56
56
|
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@volter/world-ui": "0.1.
|
|
59
|
+
"@volter/world-ui": "0.1.3",
|
|
60
60
|
"react": "^19.2.7",
|
|
61
61
|
"react-dom": "^19.2.7"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@volter/world-core": "2.0.
|
|
64
|
+
"@volter/world-core": "2.0.3"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@volter/world-core": "2.0.
|
|
67
|
+
"@volter/world-core": "2.0.3",
|
|
68
68
|
"@volter/world-tooling": "0.1.0",
|
|
69
69
|
"@types/bun": "^1.2.20",
|
|
70
70
|
"@types/node": "^24.0.0",
|
|
@@ -256,6 +256,22 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
256
256
|
return ok(g) && id(g) === id(c) && ok(u) && field(u, 'name') === 'Ada Lovelace' && field(l, 'object') === 'list';
|
|
257
257
|
}),
|
|
258
258
|
),
|
|
259
|
+
// stripe-node posts a null metadata value as `metadata[key]=` (Cal.com's PaymentIntent metadata, a guest with no phone)
|
|
260
|
+
done('stripe.metadata.create_empty_value_unset', 'metadata', 'A create\'s metadata key posted empty is not set (an empty value unsets a key); metadata posted empty is {}', 'api', 'common', () =>
|
|
261
|
+
withRoot(async (h) => {
|
|
262
|
+
const c = await h({ m: 'POST', p: '/v1/customers', b: 'email=sam@twin.test&metadata[identifier]=cal.com&metadata[bookerPhoneNumber]=' });
|
|
263
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=5000¤cy=usd&customer=${id(c)}&metadata[bookingId]=12&metadata[calAccountId]=` });
|
|
264
|
+
const none = await h({ m: 'POST', p: '/v1/products', b: 'name=Consultation&metadata=' });
|
|
265
|
+
const got = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
266
|
+
const md = (r: StripeResponse) => (r.body as Body).metadata as Body;
|
|
267
|
+
return ok(c) && JSON.stringify(md(c)) === JSON.stringify({ identifier: 'cal.com' })
|
|
268
|
+
&& ok(pi) && JSON.stringify(md(pi)) === JSON.stringify({ bookingId: '12' }) && JSON.stringify(md(got)) === JSON.stringify({ bookingId: '12' })
|
|
269
|
+
&& ok(none) && JSON.stringify(md(none)) === '{}';
|
|
270
|
+
}),
|
|
271
|
+
),
|
|
272
|
+
// Objects on a connected account are that account's: Stripe answers a retrieve of one without its Stripe-Account
|
|
273
|
+
// header (or with another account's) 404 resource_missing. The twin keeps one store for the platform and its accounts.
|
|
274
|
+
todo('stripe.connect.account_scoped_objects', 'connect', 'Objects made with a Stripe-Account header are visible only as that account (retrieve/list/update from the platform or another account is resource_missing)', 'api', 'common'),
|
|
259
275
|
done('stripe.customers.list_filter', 'customers', 'Customer list filter by email', 'api', 'core', () =>
|
|
260
276
|
withRoot(async (h) => {
|
|
261
277
|
await h({ m: 'POST', p: '/v1/customers', b: 'email=match@twin.test' });
|
package/src/stripe-events.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { nodeBuiltin, worldNow } from '@volter/world-core';
|
|
1
|
+
import { nodeBuiltin, worldNow, deliveryTraceHeaders } from '@volter/world-core';
|
|
2
2
|
import { appDestination, appFetch } from '@volter/world-core/app-route';
|
|
3
3
|
import { worldEgressRefusal } from '@volter/world-core/network-policy';
|
|
4
4
|
import vendorEvents from './generated/events.gen.json' with { type: 'json' };
|
|
@@ -274,7 +274,7 @@ async function postEvent(url: string, event: StripeEvent, payload: string, secre
|
|
|
274
274
|
for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
|
|
275
275
|
try {
|
|
276
276
|
const header = generateTestHeaderString({ payload, secret });
|
|
277
|
-
await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
|
|
277
|
+
await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() }, body: payload });
|
|
278
278
|
return;
|
|
279
279
|
} catch (error) {
|
|
280
280
|
last = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
@@ -338,7 +338,7 @@ async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret:
|
|
|
338
338
|
async function postAuthRequest(url: string, payload: string, header: string): Promise<StripeWebhookEndpointResponse> {
|
|
339
339
|
const res = await appFetch(url, {
|
|
340
340
|
method: 'POST',
|
|
341
|
-
headers: { 'content-type': 'application/json', 'stripe-signature': header },
|
|
341
|
+
headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() },
|
|
342
342
|
body: payload,
|
|
343
343
|
signal: AbortSignal.timeout(STRIPE_REALTIME_AUTH_TIMEOUT_MS),
|
|
344
344
|
});
|
package/src/stripe-mirror-ui.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
// twin internals; the client reads and writes only through Stripe's API (`/v1/...`, client/dashboard-api.ts),
|
|
5
5
|
// so it renders a twin or a real account's test data unchanged, pointed at any origin by configuration.
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
|
-
import { bundleClient, fileResponse } from '@volter/world-core';
|
|
7
|
+
import { bundleClient, fileResponse, filePathOf } from '@volter/world-core';
|
|
8
8
|
import { serveHttp } from '@volter/world-core';
|
|
9
9
|
import { createStripeTwinFetch } from './stripe-server.ts';
|
|
10
10
|
|
|
11
|
-
const CLIENT_ENTRY = () => new URL('../client/stripe-mirror.tsx', import.meta.url)
|
|
12
|
-
const CLIENT_CSS = () => new URL('../client/stripe-mirror.css', import.meta.url)
|
|
11
|
+
const CLIENT_ENTRY = () => filePathOf(new URL('../client/stripe-mirror.tsx', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
12
|
+
const CLIENT_CSS = () => filePathOf(new URL('../client/stripe-mirror.css', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Pure, dependency-free render/format/resolution helpers.
|
package/src/stripe-server.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// The surface is a plain `fetch` (`createStripeTwinFetch`) and the SERVER is one line of
|
|
8
8
|
// `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
|
|
9
9
|
// port to bind, so it mounts the fetch in-process).
|
|
10
|
-
import { bindSemantics, coreFor, createDerivedFetch, crossCutting, readParams, semanticsContext, serveHttp, vendorError, type DerivedCall, type DerivedFetch } from '@volter/world-core';
|
|
10
|
+
import { bindSemantics, coreFor, createDerivedFetch, crossCutting, derivedRequestScopes, readParams, runAsVendorMove, runWithRequestTrace, semanticsContext, serveHttp, vendorError, type DerivedCall, type DerivedFetch } from '@volter/world-core';
|
|
11
11
|
import { stripeCheckoutFlow } from './screens/checkout.tsx';
|
|
12
12
|
import { stripeJs } from './stripe-js.ts';
|
|
13
13
|
import { stripeFinancialConnectionsFlow } from './screens/financial-connections.tsx';
|
|
@@ -215,25 +215,28 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
|
|
|
215
215
|
// later: semantics/renewals.ts; each account's automatic payouts: semantics/balance.ts) are caught up to the World's clock before anything is answered, so every door
|
|
216
216
|
// (the API, the hosted pages) reads the account as it stands now
|
|
217
217
|
const billingClock = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'GetSubscriptions')!;
|
|
218
|
+
// time's moves are Stripe's own, not the caller's: they land under a read-only request too (runAsVendorMove)
|
|
218
219
|
const catchUp = async (request: Request): Promise<void> => {
|
|
219
220
|
if (readOnly) return;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
221
|
+
await runAsVendorMove(async () => {
|
|
222
|
+
const ctx = await semanticsContext(manifest, new Request(request.url), billingClock, scope);
|
|
223
|
+
// a test clock advanced by the last request has reached its time (semantics/test-clocks.ts)
|
|
224
|
+
await finishClockAdvances(ctx);
|
|
225
|
+
await advanceBilling(ctx);
|
|
226
|
+
// a submitted bank debit settles (in test mode at once) before anything is answered (semantics/payment-intents.ts)
|
|
227
|
+
await settleBankDebits(ctx);
|
|
228
|
+
// a coupon past its redeem_by is no longer valid (semantics/coupons.ts)
|
|
229
|
+
await lapseCoupons(ctx);
|
|
230
|
+
// a top-up's funds arrive five days after it is made (semantics/terminal.ts)
|
|
231
|
+
await settleTopups(ctx);
|
|
232
|
+
// a report run completes (semantics/platform.ts)
|
|
233
|
+
await finishReportRuns(ctx);
|
|
234
|
+
// a real-time authorization request no one answered is decided when its window ends (semantics/issuing.ts)
|
|
235
|
+
await lapseRealtimeRequests(ctx);
|
|
236
|
+
// a refund held for want of balance is made when funds cover it, before any payout takes them (semantics/refunds.ts)
|
|
237
|
+
await settleHeldRefunds(ctx);
|
|
238
|
+
await advancePayouts(ctx);
|
|
239
|
+
});
|
|
237
240
|
};
|
|
238
241
|
// Time's EVENTS, sent on the vendor's clock through a twin-only drain door, as the qstash and vercel twins send
|
|
239
242
|
// theirs (a caller — a runner's drainer — drives each move). POST /_twin/drain catches time up as every request
|
|
@@ -309,7 +312,10 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
|
|
|
309
312
|
const answered = creates ? body : withoutEndpointSecret(body);
|
|
310
313
|
return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, await expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
311
314
|
};
|
|
312
|
-
|
|
315
|
+
// the request's W3C trace context scopes every door (the API, the hosted flows, the drain): their writes record it and
|
|
316
|
+
// the webhooks they cause continue it (world-core trace-context)
|
|
317
|
+
// a read-only request (x-volter-read-only) writes nothing, answered with Stripe's read-only error
|
|
318
|
+
return derivedRequestScopes(manifest, Object.assign((request: Request) => runWithRequestTrace(request, () => rendered(request)), { owners: derived.owners }));
|
|
313
319
|
}
|
|
314
320
|
|
|
315
321
|
export async function createStripeTwinServer(options: StripeTwinOptions): Promise<{ port: number; stop: () => void }> {
|
|
@@ -321,6 +327,31 @@ export async function createStripeTwinServer(options: StripeTwinOptions): Promis
|
|
|
321
327
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
322
328
|
}
|
|
323
329
|
|
|
330
|
+
/** A create's `metadata` holds no key posted empty. "Individual keys can be unset by posting an empty value to them. All
|
|
331
|
+
* keys can be unset by posting an empty value to `metadata`" (every metadata parameter of docs.stripe.com/api): an empty
|
|
332
|
+
* value is the absence of the key, on a create as on an update, so an object never holds a key set to "". Clients post
|
|
333
|
+
* one without meaning to: stripe-node encodes a `null` value as `metadata[key]=` (Cal.com's PaymentIntent metadata
|
|
334
|
+
* carries `bookerPhoneNumber: null` for a guest who gave no phone). The request is rewritten as JSON without them. */
|
|
335
|
+
async function createMetadata(request: Request, path: string): Promise<Request> {
|
|
336
|
+
const op = CREATES.find((c) => c.re.test(path))?.op;
|
|
337
|
+
if (!op) return request;
|
|
338
|
+
const params = await readParams(manifest, request.clone(), op);
|
|
339
|
+
if (!('metadata' in params)) return request;
|
|
340
|
+
const given = params.metadata;
|
|
341
|
+
let metadata: Record<string, unknown>;
|
|
342
|
+
if (given === '') metadata = {};
|
|
343
|
+
else if (given && typeof given === 'object' && !Array.isArray(given) && Object.values(given).includes('')) {
|
|
344
|
+
metadata = Object.fromEntries(Object.entries(given as Record<string, unknown>).filter(([, v]) => v !== ''));
|
|
345
|
+
} else return request;
|
|
346
|
+
const headers = new Headers(request.headers);
|
|
347
|
+
headers.set('content-type', 'application/json');
|
|
348
|
+
return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...params, metadata }) });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// the create operations, by their path as a pattern, for createMetadata
|
|
352
|
+
const CREATES = (surface.operations as Array<DerivedCall['operation']>).filter((o) => o.class === 'create' && o.method.toUpperCase() === 'POST')
|
|
353
|
+
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
354
|
+
|
|
324
355
|
// the update operations, by their path as a pattern, for mergeMetadata
|
|
325
356
|
const UPDATES = (surface.operations as Array<DerivedCall['operation']>).filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
|
|
326
357
|
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
@@ -339,7 +370,8 @@ async function mergeMetadata(request: Request, coreMerges: (op: DerivedCall['ope
|
|
|
339
370
|
let op: DerivedCall['operation'] | undefined;
|
|
340
371
|
let id = '';
|
|
341
372
|
for (const u of UPDATES) { const m = u.re.exec(path); if (m) { op = u.op; id = decodeURIComponent(m.at(-1) ?? ''); break; } }
|
|
342
|
-
if (!op
|
|
373
|
+
if (!op) return createMetadata(request, path);
|
|
374
|
+
if (!op.resource) return request;
|
|
343
375
|
const params = await readParams(manifest, request.clone(), op);
|
|
344
376
|
if (!('metadata' in params)) return request;
|
|
345
377
|
const given = params.metadata;
|