@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&currency=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?.resource)
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.2",
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.2",
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.2"
64
+ "@volter/world-core": "2.0.3"
65
65
  },
66
66
  "devDependencies": {
67
- "@volter/world-core": "2.0.2",
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&currency=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' });
@@ -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?.resource) return request;
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;