@volter/twin-stripe 2.0.2 → 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.
|
@@ -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' });
|
|
@@ -346,6 +346,34 @@ export async function createStripeTwinServer(options) {
|
|
|
346
346
|
});
|
|
347
347
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
348
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, '([^/]+)')}/?$`) }));
|
|
349
377
|
// the update operations, by their path as a pattern, for mergeMetadata
|
|
350
378
|
const UPDATES = surface.operations.filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
|
|
351
379
|
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
@@ -371,7 +399,9 @@ async function mergeMetadata(request, coreMerges, scope) {
|
|
|
371
399
|
break;
|
|
372
400
|
}
|
|
373
401
|
}
|
|
374
|
-
if (!op
|
|
402
|
+
if (!op)
|
|
403
|
+
return createMetadata(request, path);
|
|
404
|
+
if (!op.resource)
|
|
375
405
|
return request;
|
|
376
406
|
const params = await readParams(manifest, request.clone(), op);
|
|
377
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-server.ts
CHANGED
|
@@ -327,6 +327,31 @@ export async function createStripeTwinServer(options: StripeTwinOptions): Promis
|
|
|
327
327
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
328
328
|
}
|
|
329
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
|
+
|
|
330
355
|
// the update operations, by their path as a pattern, for mergeMetadata
|
|
331
356
|
const UPDATES = (surface.operations as Array<DerivedCall['operation']>).filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
|
|
332
357
|
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
@@ -345,7 +370,8 @@ async function mergeMetadata(request: Request, coreMerges: (op: DerivedCall['ope
|
|
|
345
370
|
let op: DerivedCall['operation'] | undefined;
|
|
346
371
|
let id = '';
|
|
347
372
|
for (const u of UPDATES) { const m = u.re.exec(path); if (m) { op = u.op; id = decodeURIComponent(m.at(-1) ?? ''); break; } }
|
|
348
|
-
if (!op
|
|
373
|
+
if (!op) return createMetadata(request, path);
|
|
374
|
+
if (!op.resource) return request;
|
|
349
375
|
const params = await readParams(manifest, request.clone(), op);
|
|
350
376
|
if (!('metadata' in params)) return request;
|
|
351
377
|
const given = params.metadata;
|