@stardeck-customer-apps/testing 0.23.0 → 0.24.0

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/SKILL.md CHANGED
@@ -208,6 +208,14 @@ app.payments.failNext("stripe.getCheckoutSessionStatus", {
208
208
  message: "connection reset",
209
209
  });
210
210
 
211
+ // Stripe createCheckout idempotency + expiry (payments-sdk >= 4.11.0) is simulated like the
212
+ // platform: same idempotencyKey + same payload replays the same session (one captured
213
+ // checkout); same key + different payload -> PaymentError "idempotency_conflict" (409); keys
214
+ // are scoped per account; expiresAt (unix seconds) must be 30 min - 24 h out or
215
+ // "expiry_out_of_range" (400); the default is 24 h and session.expiresAt is returned.
216
+ // Beam payment links reject idempotencyKey ("idempotency_unsupported") and echo expiresAt.
217
+ // The simulator does not model Stripe's 24 h key retention.
218
+
211
219
  // File upload
212
220
  const storage = new StorageClient();
213
221
  await storage.upload(new File(["hi"], "note.txt", { type: "text/plain" }));
package/dist/index.js CHANGED
@@ -340,6 +340,7 @@ var state = globalSingleton("state", () => ({
340
340
  checkouts: [],
341
341
  checkoutCounter: 0,
342
342
  sessionStatuses: /* @__PURE__ */ new Map(),
343
+ idempotentCheckouts: /* @__PURE__ */ new Map(),
343
344
  paymentLinks: /* @__PURE__ */ new Map(),
344
345
  paymentFailures: [],
345
346
  products: [],
@@ -2131,6 +2132,8 @@ function signEventDelivery(secret, context, rawBody) {
2131
2132
  }
2132
2133
 
2133
2134
  // src/simulator/payments.ts
2135
+ var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
2136
+ var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
2134
2137
  var BEAM_BOLT_PAYMENT_METHODS = [
2135
2138
  "CARD",
2136
2139
  "CARD_INSTALLMENTS",
@@ -2200,6 +2203,15 @@ function isBoltPaymentMethod(value) {
2200
2203
  function isBoltIntentStatus(value) {
2201
2204
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
2202
2205
  }
2206
+ function stableStringify(value) {
2207
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2208
+ if (value && typeof value === "object") {
2209
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2210
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
2211
+ }
2212
+ return JSON.stringify(value);
2213
+ }
2214
+ var ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
2203
2215
  function nextCheckoutId() {
2204
2216
  state.checkoutCounter += 1;
2205
2217
  return `cs_test_${state.checkoutCounter}`;
@@ -2220,7 +2232,7 @@ function nextBoltChargeId() {
2220
2232
  state.boltChargeCounter += 1;
2221
2233
  return `chrg_${state.boltChargeCounter}`;
2222
2234
  }
2223
- function seedStripeSession(id, body) {
2235
+ function seedStripeSession(id, body, expiresAt) {
2224
2236
  const lineItems = body.lineItems ?? [];
2225
2237
  let amountTotal = null;
2226
2238
  let currency = null;
@@ -2240,7 +2252,7 @@ function seedStripeSession(id, body) {
2240
2252
  currency,
2241
2253
  customerEmail: body.customerEmail ? String(body.customerEmail) : null,
2242
2254
  metadata: body.metadata ?? {},
2243
- expiresAt: Math.floor(Date.now() / 1e3) + 3600
2255
+ expiresAt
2244
2256
  });
2245
2257
  }
2246
2258
  function seedBeamLink(id, body, merchantId) {
@@ -2260,7 +2272,8 @@ function seedBeamLink(id, body, merchantId) {
2260
2272
  },
2261
2273
  redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
2262
2274
  linkSettings: body.linkSettings,
2263
- collectDeliveryAddress: body.collectDeliveryAddress === true
2275
+ collectDeliveryAddress: body.collectDeliveryAddress === true,
2276
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
2264
2277
  });
2265
2278
  }
2266
2279
  function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
@@ -2562,6 +2575,16 @@ async function handlePaymentsRequest(request, url) {
2562
2575
  const linkId = beamProductsMatch[2];
2563
2576
  if (!linkId && request.method === "POST") {
2564
2577
  const body = await readJsonBody(request);
2578
+ if ("idempotencyKey" in body) {
2579
+ return payErr(
2580
+ "idempotencyKey is not supported for Beam payment links.",
2581
+ 400,
2582
+ "idempotency_unsupported"
2583
+ );
2584
+ }
2585
+ if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
2586
+ return payErr("expiresAt must be an ISO 8601 timestamp", 400);
2587
+ }
2565
2588
  const id = nextPaymentLinkId();
2566
2589
  const checkoutUrl = `https://beam.test/pay/${id}`;
2567
2590
  const captured = {
@@ -2574,7 +2597,11 @@ async function handlePaymentsRequest(request, url) {
2574
2597
  };
2575
2598
  state.checkouts.push(captured);
2576
2599
  seedBeamLink(id, body, merchantId);
2577
- return json({ id, url: checkoutUrl });
2600
+ return json({
2601
+ id,
2602
+ url: checkoutUrl,
2603
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
2604
+ });
2578
2605
  }
2579
2606
  if (linkId && request.method === "GET") {
2580
2607
  const link = state.paymentLinks.get(linkId);
@@ -2600,6 +2627,34 @@ async function handlePaymentsRequest(request, url) {
2600
2627
  }
2601
2628
  if (subPath === "checkout" && request.method === "POST") {
2602
2629
  const body = await readJsonBody(request);
2630
+ const nowSeconds = Math.floor(Date.now() / 1e3);
2631
+ const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
2632
+ const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
2633
+ const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
2634
+ const comparable = { ...body };
2635
+ delete comparable.deploymentId;
2636
+ delete comparable.idempotencyKey;
2637
+ const fingerprint = stableStringify(comparable);
2638
+ if (scopedKey) {
2639
+ const saved = state.idempotentCheckouts.get(scopedKey);
2640
+ if (saved) {
2641
+ if (saved.fingerprint !== fingerprint) {
2642
+ return payErr(
2643
+ "This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
2644
+ 409,
2645
+ "idempotency_conflict"
2646
+ );
2647
+ }
2648
+ return json(saved.response);
2649
+ }
2650
+ }
2651
+ if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
2652
+ return payErr(
2653
+ "expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
2654
+ 400,
2655
+ "expiry_out_of_range"
2656
+ );
2657
+ }
2603
2658
  const id = nextCheckoutId();
2604
2659
  const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
2605
2660
  const captured = {
@@ -2612,8 +2667,11 @@ async function handlePaymentsRequest(request, url) {
2612
2667
  createdAt: /* @__PURE__ */ new Date()
2613
2668
  };
2614
2669
  state.checkouts.push(captured);
2615
- seedStripeSession(id, body);
2616
- return json({ id, url: checkoutUrl });
2670
+ const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
2671
+ seedStripeSession(id, body, expiresAt);
2672
+ const response = { id, url: checkoutUrl, expiresAt };
2673
+ if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
2674
+ return json(response);
2617
2675
  }
2618
2676
  const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
2619
2677
  if (sessionMatch && request.method === "GET") {
@@ -2778,6 +2836,7 @@ function createPayments() {
2778
2836
  state.checkouts = [];
2779
2837
  state.checkoutCounter = 0;
2780
2838
  state.sessionStatuses.clear();
2839
+ state.idempotentCheckouts.clear();
2781
2840
  state.paymentLinks.clear();
2782
2841
  state.paymentFailures = [];
2783
2842
  state.products = [];
package/dist/index.mjs CHANGED
@@ -301,6 +301,7 @@ var state = globalSingleton("state", () => ({
301
301
  checkouts: [],
302
302
  checkoutCounter: 0,
303
303
  sessionStatuses: /* @__PURE__ */ new Map(),
304
+ idempotentCheckouts: /* @__PURE__ */ new Map(),
304
305
  paymentLinks: /* @__PURE__ */ new Map(),
305
306
  paymentFailures: [],
306
307
  products: [],
@@ -2092,6 +2093,8 @@ function signEventDelivery(secret, context, rawBody) {
2092
2093
  }
2093
2094
 
2094
2095
  // src/simulator/payments.ts
2096
+ var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
2097
+ var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
2095
2098
  var BEAM_BOLT_PAYMENT_METHODS = [
2096
2099
  "CARD",
2097
2100
  "CARD_INSTALLMENTS",
@@ -2161,6 +2164,15 @@ function isBoltPaymentMethod(value) {
2161
2164
  function isBoltIntentStatus(value) {
2162
2165
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
2163
2166
  }
2167
+ function stableStringify(value) {
2168
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2169
+ if (value && typeof value === "object") {
2170
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2171
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
2172
+ }
2173
+ return JSON.stringify(value);
2174
+ }
2175
+ var ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
2164
2176
  function nextCheckoutId() {
2165
2177
  state.checkoutCounter += 1;
2166
2178
  return `cs_test_${state.checkoutCounter}`;
@@ -2181,7 +2193,7 @@ function nextBoltChargeId() {
2181
2193
  state.boltChargeCounter += 1;
2182
2194
  return `chrg_${state.boltChargeCounter}`;
2183
2195
  }
2184
- function seedStripeSession(id, body) {
2196
+ function seedStripeSession(id, body, expiresAt) {
2185
2197
  const lineItems = body.lineItems ?? [];
2186
2198
  let amountTotal = null;
2187
2199
  let currency = null;
@@ -2201,7 +2213,7 @@ function seedStripeSession(id, body) {
2201
2213
  currency,
2202
2214
  customerEmail: body.customerEmail ? String(body.customerEmail) : null,
2203
2215
  metadata: body.metadata ?? {},
2204
- expiresAt: Math.floor(Date.now() / 1e3) + 3600
2216
+ expiresAt
2205
2217
  });
2206
2218
  }
2207
2219
  function seedBeamLink(id, body, merchantId) {
@@ -2221,7 +2233,8 @@ function seedBeamLink(id, body, merchantId) {
2221
2233
  },
2222
2234
  redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
2223
2235
  linkSettings: body.linkSettings,
2224
- collectDeliveryAddress: body.collectDeliveryAddress === true
2236
+ collectDeliveryAddress: body.collectDeliveryAddress === true,
2237
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
2225
2238
  });
2226
2239
  }
2227
2240
  function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
@@ -2523,6 +2536,16 @@ async function handlePaymentsRequest(request, url) {
2523
2536
  const linkId = beamProductsMatch[2];
2524
2537
  if (!linkId && request.method === "POST") {
2525
2538
  const body = await readJsonBody(request);
2539
+ if ("idempotencyKey" in body) {
2540
+ return payErr(
2541
+ "idempotencyKey is not supported for Beam payment links.",
2542
+ 400,
2543
+ "idempotency_unsupported"
2544
+ );
2545
+ }
2546
+ if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
2547
+ return payErr("expiresAt must be an ISO 8601 timestamp", 400);
2548
+ }
2526
2549
  const id = nextPaymentLinkId();
2527
2550
  const checkoutUrl = `https://beam.test/pay/${id}`;
2528
2551
  const captured = {
@@ -2535,7 +2558,11 @@ async function handlePaymentsRequest(request, url) {
2535
2558
  };
2536
2559
  state.checkouts.push(captured);
2537
2560
  seedBeamLink(id, body, merchantId);
2538
- return json({ id, url: checkoutUrl });
2561
+ return json({
2562
+ id,
2563
+ url: checkoutUrl,
2564
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
2565
+ });
2539
2566
  }
2540
2567
  if (linkId && request.method === "GET") {
2541
2568
  const link = state.paymentLinks.get(linkId);
@@ -2561,6 +2588,34 @@ async function handlePaymentsRequest(request, url) {
2561
2588
  }
2562
2589
  if (subPath === "checkout" && request.method === "POST") {
2563
2590
  const body = await readJsonBody(request);
2591
+ const nowSeconds = Math.floor(Date.now() / 1e3);
2592
+ const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
2593
+ const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
2594
+ const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
2595
+ const comparable = { ...body };
2596
+ delete comparable.deploymentId;
2597
+ delete comparable.idempotencyKey;
2598
+ const fingerprint = stableStringify(comparable);
2599
+ if (scopedKey) {
2600
+ const saved = state.idempotentCheckouts.get(scopedKey);
2601
+ if (saved) {
2602
+ if (saved.fingerprint !== fingerprint) {
2603
+ return payErr(
2604
+ "This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
2605
+ 409,
2606
+ "idempotency_conflict"
2607
+ );
2608
+ }
2609
+ return json(saved.response);
2610
+ }
2611
+ }
2612
+ if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
2613
+ return payErr(
2614
+ "expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
2615
+ 400,
2616
+ "expiry_out_of_range"
2617
+ );
2618
+ }
2564
2619
  const id = nextCheckoutId();
2565
2620
  const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
2566
2621
  const captured = {
@@ -2573,8 +2628,11 @@ async function handlePaymentsRequest(request, url) {
2573
2628
  createdAt: /* @__PURE__ */ new Date()
2574
2629
  };
2575
2630
  state.checkouts.push(captured);
2576
- seedStripeSession(id, body);
2577
- return json({ id, url: checkoutUrl });
2631
+ const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
2632
+ seedStripeSession(id, body, expiresAt);
2633
+ const response = { id, url: checkoutUrl, expiresAt };
2634
+ if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
2635
+ return json(response);
2578
2636
  }
2579
2637
  const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
2580
2638
  if (sessionMatch && request.method === "GET") {
@@ -2739,6 +2797,7 @@ function createPayments() {
2739
2797
  state.checkouts = [];
2740
2798
  state.checkoutCounter = 0;
2741
2799
  state.sessionStatuses.clear();
2800
+ state.idempotentCheckouts.clear();
2742
2801
  state.paymentLinks.clear();
2743
2802
  state.paymentFailures = [];
2744
2803
  state.products = [];
@@ -62,6 +62,7 @@ var state = globalSingleton("state", () => ({
62
62
  checkouts: [],
63
63
  checkoutCounter: 0,
64
64
  sessionStatuses: /* @__PURE__ */ new Map(),
65
+ idempotentCheckouts: /* @__PURE__ */ new Map(),
65
66
  paymentLinks: /* @__PURE__ */ new Map(),
66
67
  paymentFailures: [],
67
68
  products: [],
@@ -35,6 +35,7 @@ var state = globalSingleton("state", () => ({
35
35
  checkouts: [],
36
36
  checkoutCounter: 0,
37
37
  sessionStatuses: /* @__PURE__ */ new Map(),
38
+ idempotentCheckouts: /* @__PURE__ */ new Map(),
38
39
  paymentLinks: /* @__PURE__ */ new Map(),
39
40
  paymentFailures: [],
40
41
  products: [],
package/dist/setup.js CHANGED
@@ -67,6 +67,7 @@ var state = globalSingleton("state", () => ({
67
67
  checkouts: [],
68
68
  checkoutCounter: 0,
69
69
  sessionStatuses: /* @__PURE__ */ new Map(),
70
+ idempotentCheckouts: /* @__PURE__ */ new Map(),
70
71
  paymentLinks: /* @__PURE__ */ new Map(),
71
72
  paymentFailures: [],
72
73
  products: [],
@@ -1536,6 +1537,8 @@ var requestScopeStorage = globalSingleton(
1536
1537
  var import_node_crypto4 = __toESM(require("crypto"));
1537
1538
 
1538
1539
  // src/simulator/payments.ts
1540
+ var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
1541
+ var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
1539
1542
  var BEAM_BOLT_PAYMENT_METHODS = [
1540
1543
  "CARD",
1541
1544
  "CARD_INSTALLMENTS",
@@ -1605,6 +1608,15 @@ function isBoltPaymentMethod(value) {
1605
1608
  function isBoltIntentStatus(value) {
1606
1609
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
1607
1610
  }
1611
+ function stableStringify(value) {
1612
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1613
+ if (value && typeof value === "object") {
1614
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1615
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
1616
+ }
1617
+ return JSON.stringify(value);
1618
+ }
1619
+ var ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
1608
1620
  function nextCheckoutId() {
1609
1621
  state.checkoutCounter += 1;
1610
1622
  return `cs_test_${state.checkoutCounter}`;
@@ -1621,7 +1633,7 @@ function nextBoltIntentId() {
1621
1633
  state.boltIntentCounter += 1;
1622
1634
  return `bolti_${state.boltIntentCounter}`;
1623
1635
  }
1624
- function seedStripeSession(id, body) {
1636
+ function seedStripeSession(id, body, expiresAt) {
1625
1637
  const lineItems = body.lineItems ?? [];
1626
1638
  let amountTotal = null;
1627
1639
  let currency = null;
@@ -1641,7 +1653,7 @@ function seedStripeSession(id, body) {
1641
1653
  currency,
1642
1654
  customerEmail: body.customerEmail ? String(body.customerEmail) : null,
1643
1655
  metadata: body.metadata ?? {},
1644
- expiresAt: Math.floor(Date.now() / 1e3) + 3600
1656
+ expiresAt
1645
1657
  });
1646
1658
  }
1647
1659
  function seedBeamLink(id, body, merchantId) {
@@ -1661,7 +1673,8 @@ function seedBeamLink(id, body, merchantId) {
1661
1673
  },
1662
1674
  redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
1663
1675
  linkSettings: body.linkSettings,
1664
- collectDeliveryAddress: body.collectDeliveryAddress === true
1676
+ collectDeliveryAddress: body.collectDeliveryAddress === true,
1677
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
1665
1678
  });
1666
1679
  }
1667
1680
  function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
@@ -1932,6 +1945,16 @@ async function handlePaymentsRequest(request, url) {
1932
1945
  const linkId = beamProductsMatch[2];
1933
1946
  if (!linkId && request.method === "POST") {
1934
1947
  const body = await readJsonBody(request);
1948
+ if ("idempotencyKey" in body) {
1949
+ return payErr(
1950
+ "idempotencyKey is not supported for Beam payment links.",
1951
+ 400,
1952
+ "idempotency_unsupported"
1953
+ );
1954
+ }
1955
+ if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
1956
+ return payErr("expiresAt must be an ISO 8601 timestamp", 400);
1957
+ }
1935
1958
  const id = nextPaymentLinkId();
1936
1959
  const checkoutUrl = `https://beam.test/pay/${id}`;
1937
1960
  const captured = {
@@ -1944,7 +1967,11 @@ async function handlePaymentsRequest(request, url) {
1944
1967
  };
1945
1968
  state.checkouts.push(captured);
1946
1969
  seedBeamLink(id, body, merchantId);
1947
- return json({ id, url: checkoutUrl });
1970
+ return json({
1971
+ id,
1972
+ url: checkoutUrl,
1973
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
1974
+ });
1948
1975
  }
1949
1976
  if (linkId && request.method === "GET") {
1950
1977
  const link = state.paymentLinks.get(linkId);
@@ -1970,6 +1997,34 @@ async function handlePaymentsRequest(request, url) {
1970
1997
  }
1971
1998
  if (subPath === "checkout" && request.method === "POST") {
1972
1999
  const body = await readJsonBody(request);
2000
+ const nowSeconds = Math.floor(Date.now() / 1e3);
2001
+ const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
2002
+ const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
2003
+ const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
2004
+ const comparable = { ...body };
2005
+ delete comparable.deploymentId;
2006
+ delete comparable.idempotencyKey;
2007
+ const fingerprint = stableStringify(comparable);
2008
+ if (scopedKey) {
2009
+ const saved = state.idempotentCheckouts.get(scopedKey);
2010
+ if (saved) {
2011
+ if (saved.fingerprint !== fingerprint) {
2012
+ return payErr(
2013
+ "This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
2014
+ 409,
2015
+ "idempotency_conflict"
2016
+ );
2017
+ }
2018
+ return json(saved.response);
2019
+ }
2020
+ }
2021
+ if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
2022
+ return payErr(
2023
+ "expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
2024
+ 400,
2025
+ "expiry_out_of_range"
2026
+ );
2027
+ }
1973
2028
  const id = nextCheckoutId();
1974
2029
  const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
1975
2030
  const captured = {
@@ -1982,8 +2037,11 @@ async function handlePaymentsRequest(request, url) {
1982
2037
  createdAt: /* @__PURE__ */ new Date()
1983
2038
  };
1984
2039
  state.checkouts.push(captured);
1985
- seedStripeSession(id, body);
1986
- return json({ id, url: checkoutUrl });
2040
+ const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
2041
+ seedStripeSession(id, body, expiresAt);
2042
+ const response = { id, url: checkoutUrl, expiresAt };
2043
+ if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
2044
+ return json(response);
1987
2045
  }
1988
2046
  const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
1989
2047
  if (sessionMatch && request.method === "GET") {
package/dist/setup.mjs CHANGED
@@ -43,6 +43,7 @@ var state = globalSingleton("state", () => ({
43
43
  checkouts: [],
44
44
  checkoutCounter: 0,
45
45
  sessionStatuses: /* @__PURE__ */ new Map(),
46
+ idempotentCheckouts: /* @__PURE__ */ new Map(),
46
47
  paymentLinks: /* @__PURE__ */ new Map(),
47
48
  paymentFailures: [],
48
49
  products: [],
@@ -1512,6 +1513,8 @@ var requestScopeStorage = globalSingleton(
1512
1513
  import crypto4 from "crypto";
1513
1514
 
1514
1515
  // src/simulator/payments.ts
1516
+ var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
1517
+ var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
1515
1518
  var BEAM_BOLT_PAYMENT_METHODS = [
1516
1519
  "CARD",
1517
1520
  "CARD_INSTALLMENTS",
@@ -1581,6 +1584,15 @@ function isBoltPaymentMethod(value) {
1581
1584
  function isBoltIntentStatus(value) {
1582
1585
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
1583
1586
  }
1587
+ function stableStringify(value) {
1588
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1589
+ if (value && typeof value === "object") {
1590
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1591
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
1592
+ }
1593
+ return JSON.stringify(value);
1594
+ }
1595
+ var ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
1584
1596
  function nextCheckoutId() {
1585
1597
  state.checkoutCounter += 1;
1586
1598
  return `cs_test_${state.checkoutCounter}`;
@@ -1597,7 +1609,7 @@ function nextBoltIntentId() {
1597
1609
  state.boltIntentCounter += 1;
1598
1610
  return `bolti_${state.boltIntentCounter}`;
1599
1611
  }
1600
- function seedStripeSession(id, body) {
1612
+ function seedStripeSession(id, body, expiresAt) {
1601
1613
  const lineItems = body.lineItems ?? [];
1602
1614
  let amountTotal = null;
1603
1615
  let currency = null;
@@ -1617,7 +1629,7 @@ function seedStripeSession(id, body) {
1617
1629
  currency,
1618
1630
  customerEmail: body.customerEmail ? String(body.customerEmail) : null,
1619
1631
  metadata: body.metadata ?? {},
1620
- expiresAt: Math.floor(Date.now() / 1e3) + 3600
1632
+ expiresAt
1621
1633
  });
1622
1634
  }
1623
1635
  function seedBeamLink(id, body, merchantId) {
@@ -1637,7 +1649,8 @@ function seedBeamLink(id, body, merchantId) {
1637
1649
  },
1638
1650
  redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
1639
1651
  linkSettings: body.linkSettings,
1640
- collectDeliveryAddress: body.collectDeliveryAddress === true
1652
+ collectDeliveryAddress: body.collectDeliveryAddress === true,
1653
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
1641
1654
  });
1642
1655
  }
1643
1656
  function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
@@ -1908,6 +1921,16 @@ async function handlePaymentsRequest(request, url) {
1908
1921
  const linkId = beamProductsMatch[2];
1909
1922
  if (!linkId && request.method === "POST") {
1910
1923
  const body = await readJsonBody(request);
1924
+ if ("idempotencyKey" in body) {
1925
+ return payErr(
1926
+ "idempotencyKey is not supported for Beam payment links.",
1927
+ 400,
1928
+ "idempotency_unsupported"
1929
+ );
1930
+ }
1931
+ if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
1932
+ return payErr("expiresAt must be an ISO 8601 timestamp", 400);
1933
+ }
1911
1934
  const id = nextPaymentLinkId();
1912
1935
  const checkoutUrl = `https://beam.test/pay/${id}`;
1913
1936
  const captured = {
@@ -1920,7 +1943,11 @@ async function handlePaymentsRequest(request, url) {
1920
1943
  };
1921
1944
  state.checkouts.push(captured);
1922
1945
  seedBeamLink(id, body, merchantId);
1923
- return json({ id, url: checkoutUrl });
1946
+ return json({
1947
+ id,
1948
+ url: checkoutUrl,
1949
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
1950
+ });
1924
1951
  }
1925
1952
  if (linkId && request.method === "GET") {
1926
1953
  const link = state.paymentLinks.get(linkId);
@@ -1946,6 +1973,34 @@ async function handlePaymentsRequest(request, url) {
1946
1973
  }
1947
1974
  if (subPath === "checkout" && request.method === "POST") {
1948
1975
  const body = await readJsonBody(request);
1976
+ const nowSeconds = Math.floor(Date.now() / 1e3);
1977
+ const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
1978
+ const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
1979
+ const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
1980
+ const comparable = { ...body };
1981
+ delete comparable.deploymentId;
1982
+ delete comparable.idempotencyKey;
1983
+ const fingerprint = stableStringify(comparable);
1984
+ if (scopedKey) {
1985
+ const saved = state.idempotentCheckouts.get(scopedKey);
1986
+ if (saved) {
1987
+ if (saved.fingerprint !== fingerprint) {
1988
+ return payErr(
1989
+ "This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
1990
+ 409,
1991
+ "idempotency_conflict"
1992
+ );
1993
+ }
1994
+ return json(saved.response);
1995
+ }
1996
+ }
1997
+ if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
1998
+ return payErr(
1999
+ "expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
2000
+ 400,
2001
+ "expiry_out_of_range"
2002
+ );
2003
+ }
1949
2004
  const id = nextCheckoutId();
1950
2005
  const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
1951
2006
  const captured = {
@@ -1958,8 +2013,11 @@ async function handlePaymentsRequest(request, url) {
1958
2013
  createdAt: /* @__PURE__ */ new Date()
1959
2014
  };
1960
2015
  state.checkouts.push(captured);
1961
- seedStripeSession(id, body);
1962
- return json({ id, url: checkoutUrl });
2016
+ const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
2017
+ seedStripeSession(id, body, expiresAt);
2018
+ const response = { id, url: checkoutUrl, expiresAt };
2019
+ if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
2020
+ return json(response);
1963
2021
  }
1964
2022
  const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
1965
2023
  if (sessionMatch && request.method === "GET") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",