@blamejs/blamejs-shop 0.0.53 → 0.0.56

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/lib/payment.js CHANGED
@@ -34,6 +34,20 @@ var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
34
34
  var STRIPE_HTTP_TIMEOUT_MS = 15000;
35
35
  var CURRENCY_RE = /^[a-z]{3}$/; // Stripe wants lowercase ISO 4217
36
36
 
37
+ // Stripe holds idempotency keys for 24h, so the local cache row
38
+ // expires on the same window — operators who run `cleanupExpired()`
39
+ // on a daily schedule keep the table small without ever shortening
40
+ // the replay window below Stripe's own retention.
41
+ var IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
42
+ var IDEMPOTENCY_NAMESPACE = "payment-idempotency-body";
43
+ var IDEMPOTENT_OPERATIONS = {
44
+ "payment_intent.create": true,
45
+ "refund.create": true,
46
+ "subscription.create": true,
47
+ "subscription.update": true,
48
+ "subscription.cancel": true,
49
+ };
50
+
37
51
  // ---- validation -----------------------------------------------------------
38
52
 
39
53
  function _assertSecret(s, label) {
@@ -161,7 +175,8 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
161
175
  if (idempotencyKey) {
162
176
  headers["idempotency-key"] = idempotencyKey;
163
177
  }
164
- var res = await _b().httpClient.request({
178
+ var httpClient = opts.httpClient || _b().httpClient;
179
+ var res = await httpClient.request({
165
180
  method: method,
166
181
  url: url,
167
182
  headers: headers,
@@ -177,17 +192,138 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
177
192
  err.code = (json && json.error && json.error.code) || "STRIPE_HTTP_" + res.statusCode;
178
193
  err.statusCode = res.statusCode;
179
194
  err.stripe = json && json.error || null;
195
+ err._stripeRawText = text;
196
+ err._stripeStatus = res.statusCode;
180
197
  throw err;
181
198
  }
199
+ // Carry the raw status + serialised body alongside the parsed JSON
200
+ // so the idempotency layer can persist them verbatim for replay
201
+ // without re-stringifying (preserves byte-for-byte fidelity with
202
+ // what Stripe returned, including field ordering).
203
+ Object.defineProperty(json, "_stripeStatus", { value: res.statusCode, enumerable: false });
204
+ Object.defineProperty(json, "_stripeRawText", { value: text, enumerable: false });
182
205
  return json;
183
206
  }
184
207
 
208
+ // ---- Idempotency ----------------------------------------------------------
209
+ //
210
+ // Canonical-JSON hash. Stable across runtime, OS, node version: sort
211
+ // every object key recursively, JSON.stringify the result, then run
212
+ // through b.crypto.namespaceHash (SHA3-512). Arrays preserve order
213
+ // (their order is semantically meaningful — `items[]` in a Stripe
214
+ // subscription is an ordered list of line items).
215
+ function _canonicalise(v) {
216
+ if (v === null || typeof v !== "object") return v;
217
+ if (Array.isArray(v)) {
218
+ var out = [];
219
+ for (var i = 0; i < v.length; i += 1) out.push(_canonicalise(v[i]));
220
+ return out;
221
+ }
222
+ var keys = Object.keys(v).sort();
223
+ var obj = {};
224
+ for (var k = 0; k < keys.length; k += 1) {
225
+ var val = v[keys[k]];
226
+ if (val === undefined) continue;
227
+ obj[keys[k]] = _canonicalise(val);
228
+ }
229
+ return obj;
230
+ }
231
+
232
+ function _canonicalHash(obj) {
233
+ var canonical = JSON.stringify(_canonicalise(obj == null ? {} : obj));
234
+ return _b().crypto.namespaceHash(IDEMPOTENCY_NAMESPACE, canonical);
235
+ }
236
+
237
+ function _assertIdempotencyKey(k) {
238
+ if (typeof k !== "string" || k.length < 8 || k.length > 255) {
239
+ throw new TypeError("payment: idempotency_key must be a string between 8 and 255 characters");
240
+ }
241
+ }
242
+
243
+ // Wraps a single Stripe mutating call in the idempotency cache.
244
+ //
245
+ // 1. Look up (idempotency_key). If present + same request_hash →
246
+ // replay the stored response verbatim. If present + DIFFERENT
247
+ // request_hash → throw (security: never let a same-key replay
248
+ // with a mutated body pass through).
249
+ // 2. Otherwise, call Stripe via `doCall()`. On 2xx, INSERT the
250
+ // response row. On any throw, leave the cache empty — the next
251
+ // call with the same key retries cleanly.
252
+ async function _runIdempotent(state, operation, key, requestObj, doCall) {
253
+ _assertIdempotencyKey(key);
254
+ if (!IDEMPOTENT_OPERATIONS[operation]) {
255
+ throw new TypeError("payment: unknown idempotent operation " + JSON.stringify(operation));
256
+ }
257
+ var query = state.query;
258
+ var now = state.now();
259
+ var requestHash = _canonicalHash(requestObj);
260
+
261
+ // Replay lookup. The PRIMARY KEY index makes this an O(1) probe.
262
+ var existing = (await query(
263
+ "SELECT request_hash, response_status, response_body " +
264
+ "FROM payment_idempotency WHERE idempotency_key = ?1 LIMIT 1",
265
+ [key],
266
+ )).rows[0];
267
+
268
+ if (existing) {
269
+ if (existing.request_hash !== requestHash) {
270
+ // Same key, different body — refuse. Stripe itself would reject
271
+ // this on its own idempotency cache, but we surface a typed
272
+ // application error so the caller doesn't have to ship the
273
+ // request first to discover the collision.
274
+ throw new TypeError("payment: idempotency_key collision (different inputs)");
275
+ }
276
+ var replay = null;
277
+ try { replay = JSON.parse(existing.response_body); } catch (_e) { replay = { _raw: existing.response_body }; }
278
+ Object.defineProperty(replay, "_stripeStatus", { value: Number(existing.response_status), enumerable: false });
279
+ Object.defineProperty(replay, "_replayed", { value: true, enumerable: false });
280
+ return replay;
281
+ }
282
+
283
+ var result = await doCall();
284
+ var status = result && result._stripeStatus ? Number(result._stripeStatus) : 200;
285
+ var rawText = result && result._stripeRawText
286
+ ? result._stripeRawText
287
+ : JSON.stringify(result);
288
+
289
+ await query(
290
+ "INSERT INTO payment_idempotency " +
291
+ "(idempotency_key, operation, request_hash, response_status, response_body, created_at, expires_at) " +
292
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
293
+ [key, operation, requestHash, status, rawText, now, now + IDEMPOTENCY_TTL_MS],
294
+ );
295
+
296
+ return result;
297
+ }
298
+
185
299
  // ---- adapter --------------------------------------------------------------
186
300
 
187
301
  function stripe(opts) {
188
302
  opts = opts || {};
189
303
  _assertSecret(opts.apiKey, "apiKey");
190
304
  _assertSecret(opts.webhookSecret, "webhookSecret");
305
+ if (opts.query != null && typeof opts.query !== "function") {
306
+ throw new TypeError("payment: query must be a function (sql, params) => Promise<{ rows }>");
307
+ }
308
+ if (opts.now != null && typeof opts.now !== "function") {
309
+ throw new TypeError("payment: now must be a function returning current epoch ms");
310
+ }
311
+
312
+ // Idempotency state shared across every mutating call. When `query`
313
+ // is not supplied the primitive runs in legacy mode — every
314
+ // mutating call goes straight to Stripe, no cache writes, no
315
+ // collision detection. Operators opt in by passing `query`.
316
+ var state = {
317
+ query: opts.query || null,
318
+ now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
319
+ };
320
+
321
+ function _maybeIdempotent(operation, idempotencyKey, requestObj, doCall) {
322
+ if (!state.query || idempotencyKey == null) {
323
+ return doCall();
324
+ }
325
+ return _runIdempotent(state, operation, idempotencyKey, requestObj, doCall);
326
+ }
191
327
 
192
328
  return {
193
329
  name: "stripe",
@@ -211,7 +347,9 @@ function stripe(opts) {
211
347
  if (input.metadata) params.metadata = input.metadata;
212
348
  if (input.description) params.description = input.description;
213
349
  if (input.receipt_email) params.receipt_email = input.receipt_email;
214
- return _stripeCall(opts, "POST", "/payment_intents", params, idempotencyKey);
350
+ return _maybeIdempotent("payment_intent.create", idempotencyKey, params, function () {
351
+ return _stripeCall(opts, "POST", "/payment_intents", params, idempotencyKey);
352
+ });
215
353
  },
216
354
 
217
355
  retrievePaymentIntent: function (id) {
@@ -246,7 +384,9 @@ function stripe(opts) {
246
384
  params.reason = input.reason;
247
385
  }
248
386
  if (input.metadata) params.metadata = input.metadata;
249
- return _stripeCall(opts, "POST", "/refunds", params, idempotencyKey);
387
+ return _maybeIdempotent("refund.create", idempotencyKey, params, function () {
388
+ return _stripeCall(opts, "POST", "/refunds", params, idempotencyKey);
389
+ });
250
390
  },
251
391
 
252
392
  // Stripe Subscriptions API. Operators pre-create the recurring
@@ -270,7 +410,9 @@ function stripe(opts) {
270
410
  if (input.metadata) params.metadata = input.metadata;
271
411
  if (input.payment_behavior) params.payment_behavior = input.payment_behavior;
272
412
  if (input.expand) params.expand = input.expand;
273
- return _stripeCall(opts, "POST", "/subscriptions", params, idempotencyKey);
413
+ return _maybeIdempotent("subscription.create", idempotencyKey, params, function () {
414
+ return _stripeCall(opts, "POST", "/subscriptions", params, idempotencyKey);
415
+ });
274
416
  },
275
417
 
276
418
  retrieve: function (id) {
@@ -281,22 +423,58 @@ function stripe(opts) {
281
423
  update: function (id, input, idempotencyKey) {
282
424
  _assertSecret(id, "subscription id");
283
425
  if (!input || typeof input !== "object") throw new TypeError("payment.subscriptions.update: input object required");
284
- return _stripeCall(opts, "POST", "/subscriptions/" + encodeURIComponent(id), input, idempotencyKey);
426
+ // The hashed request body includes the subscription id so an
427
+ // update against a DIFFERENT subscription with the same key
428
+ // is detected as a collision (the id is part of the URL,
429
+ // not the body Stripe sees, but it's part of the semantic
430
+ // request — replaying against a different sub_ would be the
431
+ // same security hole as replaying with a different amount).
432
+ var hashBody = { _id: id, body: input };
433
+ return _maybeIdempotent("subscription.update", idempotencyKey, hashBody, function () {
434
+ return _stripeCall(opts, "POST", "/subscriptions/" + encodeURIComponent(id), input, idempotencyKey);
435
+ });
285
436
  },
286
437
 
287
438
  cancel: function (id, opts2, idempotencyKey) {
288
439
  _assertSecret(id, "subscription id");
289
440
  opts2 = opts2 || {};
290
- if (opts2.at_period_end) {
291
- // Stripe modeled "cancel at period end" as an UPDATE so the
292
- // subscription stays active through the current billing
293
- // window; DELETE is for immediate end-of-life.
294
- return _stripeCall(opts, "POST", "/subscriptions/" + encodeURIComponent(id),
295
- { cancel_at_period_end: true }, idempotencyKey);
296
- }
297
- return _stripeCall(opts, "DELETE", "/subscriptions/" + encodeURIComponent(id), null, idempotencyKey);
441
+ var atPeriodEnd = !!opts2.at_period_end;
442
+ var hashBody = { _id: id, at_period_end: atPeriodEnd };
443
+ return _maybeIdempotent("subscription.cancel", idempotencyKey, hashBody, function () {
444
+ if (atPeriodEnd) {
445
+ // Stripe modeled "cancel at period end" as an UPDATE so the
446
+ // subscription stays active through the current billing
447
+ // window; DELETE is for immediate end-of-life.
448
+ return _stripeCall(opts, "POST", "/subscriptions/" + encodeURIComponent(id),
449
+ { cancel_at_period_end: true }, idempotencyKey);
450
+ }
451
+ return _stripeCall(opts, "DELETE", "/subscriptions/" + encodeURIComponent(id), null, idempotencyKey);
452
+ });
298
453
  },
299
454
  },
455
+
456
+ // Purges every expired idempotency row. Operators wire this into
457
+ // a daily schedule (cron, scheduled Worker, etc.) — the table
458
+ // grows by at most one row per mutating call per 24h window and
459
+ // a daily sweep keeps the high-water mark bounded. Returns the
460
+ // number of rows removed so the operator can alert on a sudden
461
+ // spike.
462
+ cleanupExpired: async function () {
463
+ if (!state.query) {
464
+ throw new TypeError("payment.cleanupExpired: requires `query` factory opt — idempotency cache is opt-in");
465
+ }
466
+ var cutoff = state.now();
467
+ var r = await state.query(
468
+ "DELETE FROM payment_idempotency WHERE expires_at < ?1",
469
+ [cutoff],
470
+ );
471
+ // D1's DELETE result shape exposes `meta.changes`; fall back to
472
+ // `rowsAffected` for adapters that surface it differently.
473
+ if (r && r.meta && typeof r.meta.changes === "number") return r.meta.changes;
474
+ if (r && typeof r.rowsAffected === "number") return r.rowsAffected;
475
+ if (r && typeof r.changes === "number") return r.changes;
476
+ return 0;
477
+ },
300
478
  };
301
479
  }
302
480
 
@@ -312,7 +490,9 @@ module.exports = {
312
490
  create: create,
313
491
  stripe: stripe,
314
492
  STRIPE_WEBHOOK_TOLERANCE: STRIPE_WEBHOOK_TOLERANCE,
493
+ IDEMPOTENCY_TTL_MS: IDEMPOTENCY_TTL_MS,
315
494
  // Exposed for tests + Worker to share form-encoding shape.
316
495
  _formEncode: _formEncode,
317
496
  _verifyWebhook: _verifyWebhook,
497
+ _canonicalHash: _canonicalHash,
318
498
  };