@stardeck-customer-apps/testing 0.23.0 → 0.25.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" }));
@@ -557,7 +565,10 @@ cursor, limit })`; `list()` intentionally keeps its previous array shape.
557
565
  in `app.storage` against the simulated `STORAGE_URL` host.
558
566
  - `client.slack` / `client.line` / `client.facebook` send endpoints — captured
559
567
  in `app.messages`.
560
- - `createEdgeClient()` (print, pair/unpair, list devices/peripherals, show/clear
568
+ - `createEdgeClient()` (print, `enqueueReceipt()`/`getPrintJob()` durable receipts: idempotent per key,
569
+ conflicts and refused options mirrored, `app.edge.durablePrints`, and
570
+ `app.edge.settleNextDurableJob({ status: "needs_attention", reason, outcome })` to drive the
571
+ uncertain/not-printed UI; pair/unpair, list devices/peripherals, show/clear
561
572
  display, test print, `getCapabilities()`) — captured in `app.edge`; seed
562
573
  devices/peripherals for pairing pickers via `app.edge.seedDevices()` /
563
574
  `seedPeripherals()`.
package/dist/index.d.mts CHANGED
@@ -542,6 +542,27 @@ interface CapturedPrint {
542
542
  logo?: boolean;
543
543
  copies: number;
544
544
  }
545
+ /** One `edge.enqueueReceipt()` the app made, as the simulator accepted it. */
546
+ interface CapturedDurablePrint {
547
+ jobId: string;
548
+ idempotencyKey: string;
549
+ alias?: string;
550
+ deviceId?: string;
551
+ peripheralId?: string;
552
+ receipt: ReceiptPayload;
553
+ logo?: boolean;
554
+ }
555
+ /** How a simulated durable job settles. Mirrors the platform's status contract. */
556
+ type SimulatedDurableOutcome = {
557
+ status: "sent_to_printer";
558
+ } | {
559
+ status: "queued";
560
+ } | {
561
+ status: "needs_attention";
562
+ reason: string;
563
+ outcome: "not_printed" | "uncertain";
564
+ message?: string;
565
+ };
545
566
  interface CapturedDisplay {
546
567
  action: "show" | "clear";
547
568
  alias?: string;
@@ -557,6 +578,15 @@ interface TestEdge {
557
578
  get prints(): CapturedPrint[];
558
579
  get displays(): CapturedDisplay[];
559
580
  get testPrints(): CapturedTestPrint[];
581
+ /** Durable receipts accepted by `edge.enqueueReceipt()` (one per distinct idempotency key). */
582
+ get durablePrints(): CapturedDurablePrint[];
583
+ /**
584
+ * Decide how the NEXT accepted durable job settles when the app reads it with
585
+ * `edge.getPrintJob()`. Without this every job reads `sent_to_printer`. Queue
586
+ * several to model a run of jobs; use `needs_attention` with `uncertain` to
587
+ * test the "check the printer before reprinting" path.
588
+ */
589
+ settleNextDurableJob(outcome: SimulatedDurableOutcome): void;
560
590
  latestPrint(): CapturedPrint | undefined;
561
591
  latestDisplay(): CapturedDisplay | undefined;
562
592
  get bindings(): BindingInfo[];
@@ -742,4 +772,4 @@ declare const TEST_ENV_DEFAULTS: {
742
772
  /** Default location of the DDL snapshot written by `generate-types`. */
743
773
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
744
774
 
745
- export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedStarlensCall, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PaymentSimulatorFailure, type PaymentSimulatorOperation, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, type StarlensReply, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStarlens, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
775
+ export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedDurablePrint, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedStarlensCall, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PaymentSimulatorFailure, type PaymentSimulatorOperation, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, type SimulatedDurableOutcome, type StarlensReply, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStarlens, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.d.ts CHANGED
@@ -542,6 +542,27 @@ interface CapturedPrint {
542
542
  logo?: boolean;
543
543
  copies: number;
544
544
  }
545
+ /** One `edge.enqueueReceipt()` the app made, as the simulator accepted it. */
546
+ interface CapturedDurablePrint {
547
+ jobId: string;
548
+ idempotencyKey: string;
549
+ alias?: string;
550
+ deviceId?: string;
551
+ peripheralId?: string;
552
+ receipt: ReceiptPayload;
553
+ logo?: boolean;
554
+ }
555
+ /** How a simulated durable job settles. Mirrors the platform's status contract. */
556
+ type SimulatedDurableOutcome = {
557
+ status: "sent_to_printer";
558
+ } | {
559
+ status: "queued";
560
+ } | {
561
+ status: "needs_attention";
562
+ reason: string;
563
+ outcome: "not_printed" | "uncertain";
564
+ message?: string;
565
+ };
545
566
  interface CapturedDisplay {
546
567
  action: "show" | "clear";
547
568
  alias?: string;
@@ -557,6 +578,15 @@ interface TestEdge {
557
578
  get prints(): CapturedPrint[];
558
579
  get displays(): CapturedDisplay[];
559
580
  get testPrints(): CapturedTestPrint[];
581
+ /** Durable receipts accepted by `edge.enqueueReceipt()` (one per distinct idempotency key). */
582
+ get durablePrints(): CapturedDurablePrint[];
583
+ /**
584
+ * Decide how the NEXT accepted durable job settles when the app reads it with
585
+ * `edge.getPrintJob()`. Without this every job reads `sent_to_printer`. Queue
586
+ * several to model a run of jobs; use `needs_attention` with `uncertain` to
587
+ * test the "check the printer before reprinting" path.
588
+ */
589
+ settleNextDurableJob(outcome: SimulatedDurableOutcome): void;
560
590
  latestPrint(): CapturedPrint | undefined;
561
591
  latestDisplay(): CapturedDisplay | undefined;
562
592
  get bindings(): BindingInfo[];
@@ -742,4 +772,4 @@ declare const TEST_ENV_DEFAULTS: {
742
772
  /** Default location of the DDL snapshot written by `generate-types`. */
743
773
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
744
774
 
745
- export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedStarlensCall, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PaymentSimulatorFailure, type PaymentSimulatorOperation, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, type StarlensReply, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStarlens, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
775
+ export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedDurablePrint, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedStarlensCall, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PaymentSimulatorFailure, type PaymentSimulatorOperation, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, type SimulatedDurableOutcome, type StarlensReply, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStarlens, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
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: [],
@@ -364,6 +365,8 @@ var state = globalSingleton("state", () => ({
364
365
  edgeDisplays: [],
365
366
  edgeTestPrints: [],
366
367
  edgePrintCounter: 0,
368
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
369
+ edgeDurableOutcomes: [],
367
370
  edgeDevices: [],
368
371
  edgePeripherals: [],
369
372
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -2131,6 +2134,8 @@ function signEventDelivery(secret, context, rawBody) {
2131
2134
  }
2132
2135
 
2133
2136
  // src/simulator/payments.ts
2137
+ var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
2138
+ var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
2134
2139
  var BEAM_BOLT_PAYMENT_METHODS = [
2135
2140
  "CARD",
2136
2141
  "CARD_INSTALLMENTS",
@@ -2200,6 +2205,15 @@ function isBoltPaymentMethod(value) {
2200
2205
  function isBoltIntentStatus(value) {
2201
2206
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
2202
2207
  }
2208
+ function stableStringify(value) {
2209
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2210
+ if (value && typeof value === "object") {
2211
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2212
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
2213
+ }
2214
+ return JSON.stringify(value);
2215
+ }
2216
+ 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
2217
  function nextCheckoutId() {
2204
2218
  state.checkoutCounter += 1;
2205
2219
  return `cs_test_${state.checkoutCounter}`;
@@ -2220,7 +2234,7 @@ function nextBoltChargeId() {
2220
2234
  state.boltChargeCounter += 1;
2221
2235
  return `chrg_${state.boltChargeCounter}`;
2222
2236
  }
2223
- function seedStripeSession(id, body) {
2237
+ function seedStripeSession(id, body, expiresAt) {
2224
2238
  const lineItems = body.lineItems ?? [];
2225
2239
  let amountTotal = null;
2226
2240
  let currency = null;
@@ -2240,7 +2254,7 @@ function seedStripeSession(id, body) {
2240
2254
  currency,
2241
2255
  customerEmail: body.customerEmail ? String(body.customerEmail) : null,
2242
2256
  metadata: body.metadata ?? {},
2243
- expiresAt: Math.floor(Date.now() / 1e3) + 3600
2257
+ expiresAt
2244
2258
  });
2245
2259
  }
2246
2260
  function seedBeamLink(id, body, merchantId) {
@@ -2260,7 +2274,8 @@ function seedBeamLink(id, body, merchantId) {
2260
2274
  },
2261
2275
  redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
2262
2276
  linkSettings: body.linkSettings,
2263
- collectDeliveryAddress: body.collectDeliveryAddress === true
2277
+ collectDeliveryAddress: body.collectDeliveryAddress === true,
2278
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
2264
2279
  });
2265
2280
  }
2266
2281
  function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
@@ -2562,6 +2577,16 @@ async function handlePaymentsRequest(request, url) {
2562
2577
  const linkId = beamProductsMatch[2];
2563
2578
  if (!linkId && request.method === "POST") {
2564
2579
  const body = await readJsonBody(request);
2580
+ if ("idempotencyKey" in body) {
2581
+ return payErr(
2582
+ "idempotencyKey is not supported for Beam payment links.",
2583
+ 400,
2584
+ "idempotency_unsupported"
2585
+ );
2586
+ }
2587
+ if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
2588
+ return payErr("expiresAt must be an ISO 8601 timestamp", 400);
2589
+ }
2565
2590
  const id = nextPaymentLinkId();
2566
2591
  const checkoutUrl = `https://beam.test/pay/${id}`;
2567
2592
  const captured = {
@@ -2574,7 +2599,11 @@ async function handlePaymentsRequest(request, url) {
2574
2599
  };
2575
2600
  state.checkouts.push(captured);
2576
2601
  seedBeamLink(id, body, merchantId);
2577
- return json({ id, url: checkoutUrl });
2602
+ return json({
2603
+ id,
2604
+ url: checkoutUrl,
2605
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
2606
+ });
2578
2607
  }
2579
2608
  if (linkId && request.method === "GET") {
2580
2609
  const link = state.paymentLinks.get(linkId);
@@ -2600,6 +2629,34 @@ async function handlePaymentsRequest(request, url) {
2600
2629
  }
2601
2630
  if (subPath === "checkout" && request.method === "POST") {
2602
2631
  const body = await readJsonBody(request);
2632
+ const nowSeconds = Math.floor(Date.now() / 1e3);
2633
+ const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
2634
+ const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
2635
+ const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
2636
+ const comparable = { ...body };
2637
+ delete comparable.deploymentId;
2638
+ delete comparable.idempotencyKey;
2639
+ const fingerprint = stableStringify(comparable);
2640
+ if (scopedKey) {
2641
+ const saved = state.idempotentCheckouts.get(scopedKey);
2642
+ if (saved) {
2643
+ if (saved.fingerprint !== fingerprint) {
2644
+ return payErr(
2645
+ "This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
2646
+ 409,
2647
+ "idempotency_conflict"
2648
+ );
2649
+ }
2650
+ return json(saved.response);
2651
+ }
2652
+ }
2653
+ if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
2654
+ return payErr(
2655
+ "expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
2656
+ 400,
2657
+ "expiry_out_of_range"
2658
+ );
2659
+ }
2603
2660
  const id = nextCheckoutId();
2604
2661
  const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
2605
2662
  const captured = {
@@ -2612,8 +2669,11 @@ async function handlePaymentsRequest(request, url) {
2612
2669
  createdAt: /* @__PURE__ */ new Date()
2613
2670
  };
2614
2671
  state.checkouts.push(captured);
2615
- seedStripeSession(id, body);
2616
- return json({ id, url: checkoutUrl });
2672
+ const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
2673
+ seedStripeSession(id, body, expiresAt);
2674
+ const response = { id, url: checkoutUrl, expiresAt };
2675
+ if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
2676
+ return json(response);
2617
2677
  }
2618
2678
  const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
2619
2679
  if (sessionMatch && request.method === "GET") {
@@ -2778,6 +2838,7 @@ function createPayments() {
2778
2838
  state.checkouts = [];
2779
2839
  state.checkoutCounter = 0;
2780
2840
  state.sessionStatuses.clear();
2841
+ state.idempotentCheckouts.clear();
2781
2842
  state.paymentLinks.clear();
2782
2843
  state.paymentFailures = [];
2783
2844
  state.products = [];
@@ -3323,6 +3384,93 @@ async function handlePrint(request) {
3323
3384
  status: "completed"
3324
3385
  });
3325
3386
  }
3387
+ var IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
3388
+ async function handleEnqueue(request) {
3389
+ const body = await readJsonBody(request);
3390
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : "";
3391
+ if (!IDEMPOTENCY_KEY.test(key)) {
3392
+ return edgeFailure("idempotencyKey must be 1-128 characters of A-Z a-z 0-9 _ . : -");
3393
+ }
3394
+ if (body.openDrawer === true) {
3395
+ return edgeFailure("Durable receipts cannot open the cash drawer; a retry would kick it again");
3396
+ }
3397
+ if (body.copies !== void 0 && body.copies !== 1) {
3398
+ return edgeFailure(
3399
+ "Durable receipts print exactly one copy; submit a second job for another copy"
3400
+ );
3401
+ }
3402
+ const receipt = body.receipt ?? {};
3403
+ if (receipt.rawEscPos !== void 0) {
3404
+ return edgeFailure("rawEscPos is not supported for durable receipts");
3405
+ }
3406
+ const requestHash = JSON.stringify([
3407
+ body.alias,
3408
+ body.deviceId,
3409
+ body.peripheralId,
3410
+ receipt,
3411
+ body.logo
3412
+ ]);
3413
+ const existing = [...state.edgeDurableJobs.values()].find(
3414
+ (j) => j.captured.idempotencyKey === key
3415
+ );
3416
+ if (existing) {
3417
+ if (existing.requestHash !== requestHash) {
3418
+ return edgeFailure(
3419
+ "This idempotencyKey was already used for a different receipt, target or options.",
3420
+ 409,
3421
+ "IDEMPOTENCY_KEY_CONFLICT"
3422
+ );
3423
+ }
3424
+ return json({ success: true, data: durableView(existing.captured.jobId, "queued") }, 200);
3425
+ }
3426
+ const jobId = nextJobId();
3427
+ const captured = {
3428
+ jobId,
3429
+ idempotencyKey: key,
3430
+ alias: body.alias ? String(body.alias) : void 0,
3431
+ deviceId: body.deviceId ? String(body.deviceId) : void 0,
3432
+ peripheralId: body.peripheralId ? String(body.peripheralId) : void 0,
3433
+ receipt,
3434
+ logo: body.logo === true ? true : body.logo === false ? false : void 0
3435
+ };
3436
+ const outcome = state.edgeDurableOutcomes.shift() ?? { status: "sent_to_printer" };
3437
+ state.edgeDurableJobs.set(jobId, { requestHash, captured, outcome });
3438
+ return json({ success: true, data: durableView(jobId, "queued") }, 202);
3439
+ }
3440
+ function durableView(jobId, as) {
3441
+ const job = state.edgeDurableJobs.get(jobId);
3442
+ const createdAt = now2();
3443
+ const base = {
3444
+ jobId,
3445
+ attempts: as === "queued" ? 0 : 1,
3446
+ createdAt,
3447
+ expiresAt: new Date(Date.parse(createdAt) + 10 * 6e4).toISOString(),
3448
+ target: {
3449
+ deviceId: job.captured.deviceId ?? "sim-device",
3450
+ peripheralId: job.captured.peripheralId ?? "sim-peripheral"
3451
+ }
3452
+ };
3453
+ const outcome = as === "queued" ? { status: "queued" } : job.outcome;
3454
+ if (outcome.status === "needs_attention") {
3455
+ return {
3456
+ ...base,
3457
+ status: "needs_attention",
3458
+ attention: {
3459
+ reason: outcome.reason,
3460
+ outcome: outcome.outcome,
3461
+ message: outcome.message ?? "Delivery stopped."
3462
+ }
3463
+ };
3464
+ }
3465
+ if (outcome.status === "sent_to_printer") {
3466
+ return { ...base, status: "sent_to_printer", sentToPrinterAt: createdAt };
3467
+ }
3468
+ return { ...base, status: "queued" };
3469
+ }
3470
+ function handleGetDurable(jobId) {
3471
+ if (!state.edgeDurableJobs.has(jobId)) return edgeFailure("Print job not found", 404);
3472
+ return success(durableView(jobId));
3473
+ }
3326
3474
  async function handleShowDisplay(request) {
3327
3475
  const body = await readJsonBody(request);
3328
3476
  const captured = {
@@ -3372,6 +3520,13 @@ async function handleEdgeRequest(request, subPath) {
3372
3520
  if (subPath === "/print" && method === "POST") {
3373
3521
  return handlePrint(request);
3374
3522
  }
3523
+ if (subPath === "/print-jobs" && method === "POST") {
3524
+ return handleEnqueue(request);
3525
+ }
3526
+ const durableMatch = subPath.match(/^\/print-jobs\/([^/]+)$/);
3527
+ if (durableMatch && method === "GET") {
3528
+ return handleGetDurable(decodeURIComponent(durableMatch[1]));
3529
+ }
3375
3530
  if (subPath === "/capabilities" && method === "GET") {
3376
3531
  return handleCapabilities(request);
3377
3532
  }
@@ -3415,6 +3570,12 @@ function createEdge() {
3415
3570
  get testPrints() {
3416
3571
  return [...state.edgeTestPrints];
3417
3572
  },
3573
+ get durablePrints() {
3574
+ return [...state.edgeDurableJobs.values()].map((j) => j.captured);
3575
+ },
3576
+ settleNextDurableJob(outcome) {
3577
+ state.edgeDurableOutcomes.push(outcome);
3578
+ },
3418
3579
  latestPrint() {
3419
3580
  return state.edgePrints[state.edgePrints.length - 1];
3420
3581
  },
@@ -3451,12 +3612,14 @@ function createEdge() {
3451
3612
  state.edgeDisplays = [];
3452
3613
  state.edgeTestPrints = [];
3453
3614
  state.edgePrintCounter = 0;
3615
+ state.edgeDurableJobs.clear();
3616
+ state.edgeDurableOutcomes = [];
3454
3617
  state.edgeDevices = [];
3455
3618
  state.edgePeripherals = [];
3456
3619
  state.edgeBindings.clear();
3457
3620
  },
3458
3621
  get count() {
3459
- return state.edgePrints.length + state.edgeDisplays.length + state.edgeTestPrints.length;
3622
+ return state.edgePrints.length + state.edgeDisplays.length + state.edgeTestPrints.length + state.edgeDurableJobs.size;
3460
3623
  }
3461
3624
  };
3462
3625
  }
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: [],
@@ -325,6 +326,8 @@ var state = globalSingleton("state", () => ({
325
326
  edgeDisplays: [],
326
327
  edgeTestPrints: [],
327
328
  edgePrintCounter: 0,
329
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
330
+ edgeDurableOutcomes: [],
328
331
  edgeDevices: [],
329
332
  edgePeripherals: [],
330
333
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -2092,6 +2095,8 @@ function signEventDelivery(secret, context, rawBody) {
2092
2095
  }
2093
2096
 
2094
2097
  // src/simulator/payments.ts
2098
+ var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
2099
+ var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
2095
2100
  var BEAM_BOLT_PAYMENT_METHODS = [
2096
2101
  "CARD",
2097
2102
  "CARD_INSTALLMENTS",
@@ -2161,6 +2166,15 @@ function isBoltPaymentMethod(value) {
2161
2166
  function isBoltIntentStatus(value) {
2162
2167
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
2163
2168
  }
2169
+ function stableStringify(value) {
2170
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2171
+ if (value && typeof value === "object") {
2172
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2173
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
2174
+ }
2175
+ return JSON.stringify(value);
2176
+ }
2177
+ 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
2178
  function nextCheckoutId() {
2165
2179
  state.checkoutCounter += 1;
2166
2180
  return `cs_test_${state.checkoutCounter}`;
@@ -2181,7 +2195,7 @@ function nextBoltChargeId() {
2181
2195
  state.boltChargeCounter += 1;
2182
2196
  return `chrg_${state.boltChargeCounter}`;
2183
2197
  }
2184
- function seedStripeSession(id, body) {
2198
+ function seedStripeSession(id, body, expiresAt) {
2185
2199
  const lineItems = body.lineItems ?? [];
2186
2200
  let amountTotal = null;
2187
2201
  let currency = null;
@@ -2201,7 +2215,7 @@ function seedStripeSession(id, body) {
2201
2215
  currency,
2202
2216
  customerEmail: body.customerEmail ? String(body.customerEmail) : null,
2203
2217
  metadata: body.metadata ?? {},
2204
- expiresAt: Math.floor(Date.now() / 1e3) + 3600
2218
+ expiresAt
2205
2219
  });
2206
2220
  }
2207
2221
  function seedBeamLink(id, body, merchantId) {
@@ -2221,7 +2235,8 @@ function seedBeamLink(id, body, merchantId) {
2221
2235
  },
2222
2236
  redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
2223
2237
  linkSettings: body.linkSettings,
2224
- collectDeliveryAddress: body.collectDeliveryAddress === true
2238
+ collectDeliveryAddress: body.collectDeliveryAddress === true,
2239
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
2225
2240
  });
2226
2241
  }
2227
2242
  function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
@@ -2523,6 +2538,16 @@ async function handlePaymentsRequest(request, url) {
2523
2538
  const linkId = beamProductsMatch[2];
2524
2539
  if (!linkId && request.method === "POST") {
2525
2540
  const body = await readJsonBody(request);
2541
+ if ("idempotencyKey" in body) {
2542
+ return payErr(
2543
+ "idempotencyKey is not supported for Beam payment links.",
2544
+ 400,
2545
+ "idempotency_unsupported"
2546
+ );
2547
+ }
2548
+ if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
2549
+ return payErr("expiresAt must be an ISO 8601 timestamp", 400);
2550
+ }
2526
2551
  const id = nextPaymentLinkId();
2527
2552
  const checkoutUrl = `https://beam.test/pay/${id}`;
2528
2553
  const captured = {
@@ -2535,7 +2560,11 @@ async function handlePaymentsRequest(request, url) {
2535
2560
  };
2536
2561
  state.checkouts.push(captured);
2537
2562
  seedBeamLink(id, body, merchantId);
2538
- return json({ id, url: checkoutUrl });
2563
+ return json({
2564
+ id,
2565
+ url: checkoutUrl,
2566
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
2567
+ });
2539
2568
  }
2540
2569
  if (linkId && request.method === "GET") {
2541
2570
  const link = state.paymentLinks.get(linkId);
@@ -2561,6 +2590,34 @@ async function handlePaymentsRequest(request, url) {
2561
2590
  }
2562
2591
  if (subPath === "checkout" && request.method === "POST") {
2563
2592
  const body = await readJsonBody(request);
2593
+ const nowSeconds = Math.floor(Date.now() / 1e3);
2594
+ const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
2595
+ const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
2596
+ const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
2597
+ const comparable = { ...body };
2598
+ delete comparable.deploymentId;
2599
+ delete comparable.idempotencyKey;
2600
+ const fingerprint = stableStringify(comparable);
2601
+ if (scopedKey) {
2602
+ const saved = state.idempotentCheckouts.get(scopedKey);
2603
+ if (saved) {
2604
+ if (saved.fingerprint !== fingerprint) {
2605
+ return payErr(
2606
+ "This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
2607
+ 409,
2608
+ "idempotency_conflict"
2609
+ );
2610
+ }
2611
+ return json(saved.response);
2612
+ }
2613
+ }
2614
+ if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
2615
+ return payErr(
2616
+ "expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
2617
+ 400,
2618
+ "expiry_out_of_range"
2619
+ );
2620
+ }
2564
2621
  const id = nextCheckoutId();
2565
2622
  const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
2566
2623
  const captured = {
@@ -2573,8 +2630,11 @@ async function handlePaymentsRequest(request, url) {
2573
2630
  createdAt: /* @__PURE__ */ new Date()
2574
2631
  };
2575
2632
  state.checkouts.push(captured);
2576
- seedStripeSession(id, body);
2577
- return json({ id, url: checkoutUrl });
2633
+ const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
2634
+ seedStripeSession(id, body, expiresAt);
2635
+ const response = { id, url: checkoutUrl, expiresAt };
2636
+ if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
2637
+ return json(response);
2578
2638
  }
2579
2639
  const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
2580
2640
  if (sessionMatch && request.method === "GET") {
@@ -2739,6 +2799,7 @@ function createPayments() {
2739
2799
  state.checkouts = [];
2740
2800
  state.checkoutCounter = 0;
2741
2801
  state.sessionStatuses.clear();
2802
+ state.idempotentCheckouts.clear();
2742
2803
  state.paymentLinks.clear();
2743
2804
  state.paymentFailures = [];
2744
2805
  state.products = [];
@@ -3284,6 +3345,93 @@ async function handlePrint(request) {
3284
3345
  status: "completed"
3285
3346
  });
3286
3347
  }
3348
+ var IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
3349
+ async function handleEnqueue(request) {
3350
+ const body = await readJsonBody(request);
3351
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : "";
3352
+ if (!IDEMPOTENCY_KEY.test(key)) {
3353
+ return edgeFailure("idempotencyKey must be 1-128 characters of A-Z a-z 0-9 _ . : -");
3354
+ }
3355
+ if (body.openDrawer === true) {
3356
+ return edgeFailure("Durable receipts cannot open the cash drawer; a retry would kick it again");
3357
+ }
3358
+ if (body.copies !== void 0 && body.copies !== 1) {
3359
+ return edgeFailure(
3360
+ "Durable receipts print exactly one copy; submit a second job for another copy"
3361
+ );
3362
+ }
3363
+ const receipt = body.receipt ?? {};
3364
+ if (receipt.rawEscPos !== void 0) {
3365
+ return edgeFailure("rawEscPos is not supported for durable receipts");
3366
+ }
3367
+ const requestHash = JSON.stringify([
3368
+ body.alias,
3369
+ body.deviceId,
3370
+ body.peripheralId,
3371
+ receipt,
3372
+ body.logo
3373
+ ]);
3374
+ const existing = [...state.edgeDurableJobs.values()].find(
3375
+ (j) => j.captured.idempotencyKey === key
3376
+ );
3377
+ if (existing) {
3378
+ if (existing.requestHash !== requestHash) {
3379
+ return edgeFailure(
3380
+ "This idempotencyKey was already used for a different receipt, target or options.",
3381
+ 409,
3382
+ "IDEMPOTENCY_KEY_CONFLICT"
3383
+ );
3384
+ }
3385
+ return json({ success: true, data: durableView(existing.captured.jobId, "queued") }, 200);
3386
+ }
3387
+ const jobId = nextJobId();
3388
+ const captured = {
3389
+ jobId,
3390
+ idempotencyKey: key,
3391
+ alias: body.alias ? String(body.alias) : void 0,
3392
+ deviceId: body.deviceId ? String(body.deviceId) : void 0,
3393
+ peripheralId: body.peripheralId ? String(body.peripheralId) : void 0,
3394
+ receipt,
3395
+ logo: body.logo === true ? true : body.logo === false ? false : void 0
3396
+ };
3397
+ const outcome = state.edgeDurableOutcomes.shift() ?? { status: "sent_to_printer" };
3398
+ state.edgeDurableJobs.set(jobId, { requestHash, captured, outcome });
3399
+ return json({ success: true, data: durableView(jobId, "queued") }, 202);
3400
+ }
3401
+ function durableView(jobId, as) {
3402
+ const job = state.edgeDurableJobs.get(jobId);
3403
+ const createdAt = now2();
3404
+ const base = {
3405
+ jobId,
3406
+ attempts: as === "queued" ? 0 : 1,
3407
+ createdAt,
3408
+ expiresAt: new Date(Date.parse(createdAt) + 10 * 6e4).toISOString(),
3409
+ target: {
3410
+ deviceId: job.captured.deviceId ?? "sim-device",
3411
+ peripheralId: job.captured.peripheralId ?? "sim-peripheral"
3412
+ }
3413
+ };
3414
+ const outcome = as === "queued" ? { status: "queued" } : job.outcome;
3415
+ if (outcome.status === "needs_attention") {
3416
+ return {
3417
+ ...base,
3418
+ status: "needs_attention",
3419
+ attention: {
3420
+ reason: outcome.reason,
3421
+ outcome: outcome.outcome,
3422
+ message: outcome.message ?? "Delivery stopped."
3423
+ }
3424
+ };
3425
+ }
3426
+ if (outcome.status === "sent_to_printer") {
3427
+ return { ...base, status: "sent_to_printer", sentToPrinterAt: createdAt };
3428
+ }
3429
+ return { ...base, status: "queued" };
3430
+ }
3431
+ function handleGetDurable(jobId) {
3432
+ if (!state.edgeDurableJobs.has(jobId)) return edgeFailure("Print job not found", 404);
3433
+ return success(durableView(jobId));
3434
+ }
3287
3435
  async function handleShowDisplay(request) {
3288
3436
  const body = await readJsonBody(request);
3289
3437
  const captured = {
@@ -3333,6 +3481,13 @@ async function handleEdgeRequest(request, subPath) {
3333
3481
  if (subPath === "/print" && method === "POST") {
3334
3482
  return handlePrint(request);
3335
3483
  }
3484
+ if (subPath === "/print-jobs" && method === "POST") {
3485
+ return handleEnqueue(request);
3486
+ }
3487
+ const durableMatch = subPath.match(/^\/print-jobs\/([^/]+)$/);
3488
+ if (durableMatch && method === "GET") {
3489
+ return handleGetDurable(decodeURIComponent(durableMatch[1]));
3490
+ }
3336
3491
  if (subPath === "/capabilities" && method === "GET") {
3337
3492
  return handleCapabilities(request);
3338
3493
  }
@@ -3376,6 +3531,12 @@ function createEdge() {
3376
3531
  get testPrints() {
3377
3532
  return [...state.edgeTestPrints];
3378
3533
  },
3534
+ get durablePrints() {
3535
+ return [...state.edgeDurableJobs.values()].map((j) => j.captured);
3536
+ },
3537
+ settleNextDurableJob(outcome) {
3538
+ state.edgeDurableOutcomes.push(outcome);
3539
+ },
3379
3540
  latestPrint() {
3380
3541
  return state.edgePrints[state.edgePrints.length - 1];
3381
3542
  },
@@ -3412,12 +3573,14 @@ function createEdge() {
3412
3573
  state.edgeDisplays = [];
3413
3574
  state.edgeTestPrints = [];
3414
3575
  state.edgePrintCounter = 0;
3576
+ state.edgeDurableJobs.clear();
3577
+ state.edgeDurableOutcomes = [];
3415
3578
  state.edgeDevices = [];
3416
3579
  state.edgePeripherals = [];
3417
3580
  state.edgeBindings.clear();
3418
3581
  },
3419
3582
  get count() {
3420
- return state.edgePrints.length + state.edgeDisplays.length + state.edgeTestPrints.length;
3583
+ return state.edgePrints.length + state.edgeDisplays.length + state.edgeTestPrints.length + state.edgeDurableJobs.size;
3421
3584
  }
3422
3585
  };
3423
3586
  }
@@ -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: [],
@@ -86,6 +87,8 @@ var state = globalSingleton("state", () => ({
86
87
  edgeDisplays: [],
87
88
  edgeTestPrints: [],
88
89
  edgePrintCounter: 0,
90
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
91
+ edgeDurableOutcomes: [],
89
92
  edgeDevices: [],
90
93
  edgePeripherals: [],
91
94
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -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: [],
@@ -59,6 +60,8 @@ var state = globalSingleton("state", () => ({
59
60
  edgeDisplays: [],
60
61
  edgeTestPrints: [],
61
62
  edgePrintCounter: 0,
63
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
64
+ edgeDurableOutcomes: [],
62
65
  edgeDevices: [],
63
66
  edgePeripherals: [],
64
67
  edgeBindings: /* @__PURE__ */ new Map(),
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: [],
@@ -91,6 +92,8 @@ var state = globalSingleton("state", () => ({
91
92
  edgeDisplays: [],
92
93
  edgeTestPrints: [],
93
94
  edgePrintCounter: 0,
95
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
96
+ edgeDurableOutcomes: [],
94
97
  edgeDevices: [],
95
98
  edgePeripherals: [],
96
99
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -1536,6 +1539,8 @@ var requestScopeStorage = globalSingleton(
1536
1539
  var import_node_crypto4 = __toESM(require("crypto"));
1537
1540
 
1538
1541
  // src/simulator/payments.ts
1542
+ var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
1543
+ var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
1539
1544
  var BEAM_BOLT_PAYMENT_METHODS = [
1540
1545
  "CARD",
1541
1546
  "CARD_INSTALLMENTS",
@@ -1605,6 +1610,15 @@ function isBoltPaymentMethod(value) {
1605
1610
  function isBoltIntentStatus(value) {
1606
1611
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
1607
1612
  }
1613
+ function stableStringify(value) {
1614
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1615
+ if (value && typeof value === "object") {
1616
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1617
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
1618
+ }
1619
+ return JSON.stringify(value);
1620
+ }
1621
+ 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
1622
  function nextCheckoutId() {
1609
1623
  state.checkoutCounter += 1;
1610
1624
  return `cs_test_${state.checkoutCounter}`;
@@ -1621,7 +1635,7 @@ function nextBoltIntentId() {
1621
1635
  state.boltIntentCounter += 1;
1622
1636
  return `bolti_${state.boltIntentCounter}`;
1623
1637
  }
1624
- function seedStripeSession(id, body) {
1638
+ function seedStripeSession(id, body, expiresAt) {
1625
1639
  const lineItems = body.lineItems ?? [];
1626
1640
  let amountTotal = null;
1627
1641
  let currency = null;
@@ -1641,7 +1655,7 @@ function seedStripeSession(id, body) {
1641
1655
  currency,
1642
1656
  customerEmail: body.customerEmail ? String(body.customerEmail) : null,
1643
1657
  metadata: body.metadata ?? {},
1644
- expiresAt: Math.floor(Date.now() / 1e3) + 3600
1658
+ expiresAt
1645
1659
  });
1646
1660
  }
1647
1661
  function seedBeamLink(id, body, merchantId) {
@@ -1661,7 +1675,8 @@ function seedBeamLink(id, body, merchantId) {
1661
1675
  },
1662
1676
  redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
1663
1677
  linkSettings: body.linkSettings,
1664
- collectDeliveryAddress: body.collectDeliveryAddress === true
1678
+ collectDeliveryAddress: body.collectDeliveryAddress === true,
1679
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
1665
1680
  });
1666
1681
  }
1667
1682
  function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
@@ -1932,6 +1947,16 @@ async function handlePaymentsRequest(request, url) {
1932
1947
  const linkId = beamProductsMatch[2];
1933
1948
  if (!linkId && request.method === "POST") {
1934
1949
  const body = await readJsonBody(request);
1950
+ if ("idempotencyKey" in body) {
1951
+ return payErr(
1952
+ "idempotencyKey is not supported for Beam payment links.",
1953
+ 400,
1954
+ "idempotency_unsupported"
1955
+ );
1956
+ }
1957
+ if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
1958
+ return payErr("expiresAt must be an ISO 8601 timestamp", 400);
1959
+ }
1935
1960
  const id = nextPaymentLinkId();
1936
1961
  const checkoutUrl = `https://beam.test/pay/${id}`;
1937
1962
  const captured = {
@@ -1944,7 +1969,11 @@ async function handlePaymentsRequest(request, url) {
1944
1969
  };
1945
1970
  state.checkouts.push(captured);
1946
1971
  seedBeamLink(id, body, merchantId);
1947
- return json({ id, url: checkoutUrl });
1972
+ return json({
1973
+ id,
1974
+ url: checkoutUrl,
1975
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
1976
+ });
1948
1977
  }
1949
1978
  if (linkId && request.method === "GET") {
1950
1979
  const link = state.paymentLinks.get(linkId);
@@ -1970,6 +1999,34 @@ async function handlePaymentsRequest(request, url) {
1970
1999
  }
1971
2000
  if (subPath === "checkout" && request.method === "POST") {
1972
2001
  const body = await readJsonBody(request);
2002
+ const nowSeconds = Math.floor(Date.now() / 1e3);
2003
+ const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
2004
+ const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
2005
+ const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
2006
+ const comparable = { ...body };
2007
+ delete comparable.deploymentId;
2008
+ delete comparable.idempotencyKey;
2009
+ const fingerprint = stableStringify(comparable);
2010
+ if (scopedKey) {
2011
+ const saved = state.idempotentCheckouts.get(scopedKey);
2012
+ if (saved) {
2013
+ if (saved.fingerprint !== fingerprint) {
2014
+ return payErr(
2015
+ "This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
2016
+ 409,
2017
+ "idempotency_conflict"
2018
+ );
2019
+ }
2020
+ return json(saved.response);
2021
+ }
2022
+ }
2023
+ if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
2024
+ return payErr(
2025
+ "expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
2026
+ 400,
2027
+ "expiry_out_of_range"
2028
+ );
2029
+ }
1973
2030
  const id = nextCheckoutId();
1974
2031
  const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
1975
2032
  const captured = {
@@ -1982,8 +2039,11 @@ async function handlePaymentsRequest(request, url) {
1982
2039
  createdAt: /* @__PURE__ */ new Date()
1983
2040
  };
1984
2041
  state.checkouts.push(captured);
1985
- seedStripeSession(id, body);
1986
- return json({ id, url: checkoutUrl });
2042
+ const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
2043
+ seedStripeSession(id, body, expiresAt);
2044
+ const response = { id, url: checkoutUrl, expiresAt };
2045
+ if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
2046
+ return json(response);
1987
2047
  }
1988
2048
  const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
1989
2049
  if (sessionMatch && request.method === "GET") {
@@ -2469,6 +2529,93 @@ async function handlePrint(request) {
2469
2529
  status: "completed"
2470
2530
  });
2471
2531
  }
2532
+ var IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
2533
+ async function handleEnqueue(request) {
2534
+ const body = await readJsonBody(request);
2535
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : "";
2536
+ if (!IDEMPOTENCY_KEY.test(key)) {
2537
+ return edgeFailure("idempotencyKey must be 1-128 characters of A-Z a-z 0-9 _ . : -");
2538
+ }
2539
+ if (body.openDrawer === true) {
2540
+ return edgeFailure("Durable receipts cannot open the cash drawer; a retry would kick it again");
2541
+ }
2542
+ if (body.copies !== void 0 && body.copies !== 1) {
2543
+ return edgeFailure(
2544
+ "Durable receipts print exactly one copy; submit a second job for another copy"
2545
+ );
2546
+ }
2547
+ const receipt = body.receipt ?? {};
2548
+ if (receipt.rawEscPos !== void 0) {
2549
+ return edgeFailure("rawEscPos is not supported for durable receipts");
2550
+ }
2551
+ const requestHash = JSON.stringify([
2552
+ body.alias,
2553
+ body.deviceId,
2554
+ body.peripheralId,
2555
+ receipt,
2556
+ body.logo
2557
+ ]);
2558
+ const existing = [...state.edgeDurableJobs.values()].find(
2559
+ (j) => j.captured.idempotencyKey === key
2560
+ );
2561
+ if (existing) {
2562
+ if (existing.requestHash !== requestHash) {
2563
+ return edgeFailure(
2564
+ "This idempotencyKey was already used for a different receipt, target or options.",
2565
+ 409,
2566
+ "IDEMPOTENCY_KEY_CONFLICT"
2567
+ );
2568
+ }
2569
+ return json({ success: true, data: durableView(existing.captured.jobId, "queued") }, 200);
2570
+ }
2571
+ const jobId = nextJobId();
2572
+ const captured = {
2573
+ jobId,
2574
+ idempotencyKey: key,
2575
+ alias: body.alias ? String(body.alias) : void 0,
2576
+ deviceId: body.deviceId ? String(body.deviceId) : void 0,
2577
+ peripheralId: body.peripheralId ? String(body.peripheralId) : void 0,
2578
+ receipt,
2579
+ logo: body.logo === true ? true : body.logo === false ? false : void 0
2580
+ };
2581
+ const outcome = state.edgeDurableOutcomes.shift() ?? { status: "sent_to_printer" };
2582
+ state.edgeDurableJobs.set(jobId, { requestHash, captured, outcome });
2583
+ return json({ success: true, data: durableView(jobId, "queued") }, 202);
2584
+ }
2585
+ function durableView(jobId, as) {
2586
+ const job = state.edgeDurableJobs.get(jobId);
2587
+ const createdAt = now2();
2588
+ const base = {
2589
+ jobId,
2590
+ attempts: as === "queued" ? 0 : 1,
2591
+ createdAt,
2592
+ expiresAt: new Date(Date.parse(createdAt) + 10 * 6e4).toISOString(),
2593
+ target: {
2594
+ deviceId: job.captured.deviceId ?? "sim-device",
2595
+ peripheralId: job.captured.peripheralId ?? "sim-peripheral"
2596
+ }
2597
+ };
2598
+ const outcome = as === "queued" ? { status: "queued" } : job.outcome;
2599
+ if (outcome.status === "needs_attention") {
2600
+ return {
2601
+ ...base,
2602
+ status: "needs_attention",
2603
+ attention: {
2604
+ reason: outcome.reason,
2605
+ outcome: outcome.outcome,
2606
+ message: outcome.message ?? "Delivery stopped."
2607
+ }
2608
+ };
2609
+ }
2610
+ if (outcome.status === "sent_to_printer") {
2611
+ return { ...base, status: "sent_to_printer", sentToPrinterAt: createdAt };
2612
+ }
2613
+ return { ...base, status: "queued" };
2614
+ }
2615
+ function handleGetDurable(jobId) {
2616
+ if (!state.edgeDurableJobs.has(jobId)) return edgeFailure("Print job not found", 404);
2617
+ return success(durableView(jobId));
2618
+ }
2472
2619
  async function handleShowDisplay(request) {
2473
2620
  const body = await readJsonBody(request);
2474
2621
  const captured = {
@@ -2518,6 +2665,13 @@ async function handleEdgeRequest(request, subPath) {
2518
2665
  if (subPath === "/print" && method === "POST") {
2519
2666
  return handlePrint(request);
2520
2667
  }
2668
+ if (subPath === "/print-jobs" && method === "POST") {
2669
+ return handleEnqueue(request);
2670
+ }
2671
+ const durableMatch = subPath.match(/^\/print-jobs\/([^/]+)$/);
2672
+ if (durableMatch && method === "GET") {
2673
+ return handleGetDurable(decodeURIComponent(durableMatch[1]));
2674
+ }
2521
2675
  if (subPath === "/capabilities" && method === "GET") {
2522
2676
  return handleCapabilities(request);
2523
2677
  }
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: [],
@@ -67,6 +68,8 @@ var state = globalSingleton("state", () => ({
67
68
  edgeDisplays: [],
68
69
  edgeTestPrints: [],
69
70
  edgePrintCounter: 0,
71
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
72
+ edgeDurableOutcomes: [],
70
73
  edgeDevices: [],
71
74
  edgePeripherals: [],
72
75
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -1512,6 +1515,8 @@ var requestScopeStorage = globalSingleton(
1512
1515
  import crypto4 from "crypto";
1513
1516
 
1514
1517
  // src/simulator/payments.ts
1518
+ var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
1519
+ var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
1515
1520
  var BEAM_BOLT_PAYMENT_METHODS = [
1516
1521
  "CARD",
1517
1522
  "CARD_INSTALLMENTS",
@@ -1581,6 +1586,15 @@ function isBoltPaymentMethod(value) {
1581
1586
  function isBoltIntentStatus(value) {
1582
1587
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
1583
1588
  }
1589
+ function stableStringify(value) {
1590
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1591
+ if (value && typeof value === "object") {
1592
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1593
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
1594
+ }
1595
+ return JSON.stringify(value);
1596
+ }
1597
+ 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
1598
  function nextCheckoutId() {
1585
1599
  state.checkoutCounter += 1;
1586
1600
  return `cs_test_${state.checkoutCounter}`;
@@ -1597,7 +1611,7 @@ function nextBoltIntentId() {
1597
1611
  state.boltIntentCounter += 1;
1598
1612
  return `bolti_${state.boltIntentCounter}`;
1599
1613
  }
1600
- function seedStripeSession(id, body) {
1614
+ function seedStripeSession(id, body, expiresAt) {
1601
1615
  const lineItems = body.lineItems ?? [];
1602
1616
  let amountTotal = null;
1603
1617
  let currency = null;
@@ -1617,7 +1631,7 @@ function seedStripeSession(id, body) {
1617
1631
  currency,
1618
1632
  customerEmail: body.customerEmail ? String(body.customerEmail) : null,
1619
1633
  metadata: body.metadata ?? {},
1620
- expiresAt: Math.floor(Date.now() / 1e3) + 3600
1634
+ expiresAt
1621
1635
  });
1622
1636
  }
1623
1637
  function seedBeamLink(id, body, merchantId) {
@@ -1637,7 +1651,8 @@ function seedBeamLink(id, body, merchantId) {
1637
1651
  },
1638
1652
  redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
1639
1653
  linkSettings: body.linkSettings,
1640
- collectDeliveryAddress: body.collectDeliveryAddress === true
1654
+ collectDeliveryAddress: body.collectDeliveryAddress === true,
1655
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
1641
1656
  });
1642
1657
  }
1643
1658
  function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
@@ -1908,6 +1923,16 @@ async function handlePaymentsRequest(request, url) {
1908
1923
  const linkId = beamProductsMatch[2];
1909
1924
  if (!linkId && request.method === "POST") {
1910
1925
  const body = await readJsonBody(request);
1926
+ if ("idempotencyKey" in body) {
1927
+ return payErr(
1928
+ "idempotencyKey is not supported for Beam payment links.",
1929
+ 400,
1930
+ "idempotency_unsupported"
1931
+ );
1932
+ }
1933
+ if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
1934
+ return payErr("expiresAt must be an ISO 8601 timestamp", 400);
1935
+ }
1911
1936
  const id = nextPaymentLinkId();
1912
1937
  const checkoutUrl = `https://beam.test/pay/${id}`;
1913
1938
  const captured = {
@@ -1920,7 +1945,11 @@ async function handlePaymentsRequest(request, url) {
1920
1945
  };
1921
1946
  state.checkouts.push(captured);
1922
1947
  seedBeamLink(id, body, merchantId);
1923
- return json({ id, url: checkoutUrl });
1948
+ return json({
1949
+ id,
1950
+ url: checkoutUrl,
1951
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
1952
+ });
1924
1953
  }
1925
1954
  if (linkId && request.method === "GET") {
1926
1955
  const link = state.paymentLinks.get(linkId);
@@ -1946,6 +1975,34 @@ async function handlePaymentsRequest(request, url) {
1946
1975
  }
1947
1976
  if (subPath === "checkout" && request.method === "POST") {
1948
1977
  const body = await readJsonBody(request);
1978
+ const nowSeconds = Math.floor(Date.now() / 1e3);
1979
+ const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
1980
+ const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
1981
+ const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
1982
+ const comparable = { ...body };
1983
+ delete comparable.deploymentId;
1984
+ delete comparable.idempotencyKey;
1985
+ const fingerprint = stableStringify(comparable);
1986
+ if (scopedKey) {
1987
+ const saved = state.idempotentCheckouts.get(scopedKey);
1988
+ if (saved) {
1989
+ if (saved.fingerprint !== fingerprint) {
1990
+ return payErr(
1991
+ "This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
1992
+ 409,
1993
+ "idempotency_conflict"
1994
+ );
1995
+ }
1996
+ return json(saved.response);
1997
+ }
1998
+ }
1999
+ if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
2000
+ return payErr(
2001
+ "expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
2002
+ 400,
2003
+ "expiry_out_of_range"
2004
+ );
2005
+ }
1949
2006
  const id = nextCheckoutId();
1950
2007
  const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
1951
2008
  const captured = {
@@ -1958,8 +2015,11 @@ async function handlePaymentsRequest(request, url) {
1958
2015
  createdAt: /* @__PURE__ */ new Date()
1959
2016
  };
1960
2017
  state.checkouts.push(captured);
1961
- seedStripeSession(id, body);
1962
- return json({ id, url: checkoutUrl });
2018
+ const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
2019
+ seedStripeSession(id, body, expiresAt);
2020
+ const response = { id, url: checkoutUrl, expiresAt };
2021
+ if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
2022
+ return json(response);
1963
2023
  }
1964
2024
  const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
1965
2025
  if (sessionMatch && request.method === "GET") {
@@ -2445,6 +2505,93 @@ async function handlePrint(request) {
2445
2505
  status: "completed"
2446
2506
  });
2447
2507
  }
2508
+ var IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
2509
+ async function handleEnqueue(request) {
2510
+ const body = await readJsonBody(request);
2511
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : "";
2512
+ if (!IDEMPOTENCY_KEY.test(key)) {
2513
+ return edgeFailure("idempotencyKey must be 1-128 characters of A-Z a-z 0-9 _ . : -");
2514
+ }
2515
+ if (body.openDrawer === true) {
2516
+ return edgeFailure("Durable receipts cannot open the cash drawer; a retry would kick it again");
2517
+ }
2518
+ if (body.copies !== void 0 && body.copies !== 1) {
2519
+ return edgeFailure(
2520
+ "Durable receipts print exactly one copy; submit a second job for another copy"
2521
+ );
2522
+ }
2523
+ const receipt = body.receipt ?? {};
2524
+ if (receipt.rawEscPos !== void 0) {
2525
+ return edgeFailure("rawEscPos is not supported for durable receipts");
2526
+ }
2527
+ const requestHash = JSON.stringify([
2528
+ body.alias,
2529
+ body.deviceId,
2530
+ body.peripheralId,
2531
+ receipt,
2532
+ body.logo
2533
+ ]);
2534
+ const existing = [...state.edgeDurableJobs.values()].find(
2535
+ (j) => j.captured.idempotencyKey === key
2536
+ );
2537
+ if (existing) {
2538
+ if (existing.requestHash !== requestHash) {
2539
+ return edgeFailure(
2540
+ "This idempotencyKey was already used for a different receipt, target or options.",
2541
+ 409,
2542
+ "IDEMPOTENCY_KEY_CONFLICT"
2543
+ );
2544
+ }
2545
+ return json({ success: true, data: durableView(existing.captured.jobId, "queued") }, 200);
2546
+ }
2547
+ const jobId = nextJobId();
2548
+ const captured = {
2549
+ jobId,
2550
+ idempotencyKey: key,
2551
+ alias: body.alias ? String(body.alias) : void 0,
2552
+ deviceId: body.deviceId ? String(body.deviceId) : void 0,
2553
+ peripheralId: body.peripheralId ? String(body.peripheralId) : void 0,
2554
+ receipt,
2555
+ logo: body.logo === true ? true : body.logo === false ? false : void 0
2556
+ };
2557
+ const outcome = state.edgeDurableOutcomes.shift() ?? { status: "sent_to_printer" };
2558
+ state.edgeDurableJobs.set(jobId, { requestHash, captured, outcome });
2559
+ return json({ success: true, data: durableView(jobId, "queued") }, 202);
2560
+ }
2561
+ function durableView(jobId, as) {
2562
+ const job = state.edgeDurableJobs.get(jobId);
2563
+ const createdAt = now2();
2564
+ const base = {
2565
+ jobId,
2566
+ attempts: as === "queued" ? 0 : 1,
2567
+ createdAt,
2568
+ expiresAt: new Date(Date.parse(createdAt) + 10 * 6e4).toISOString(),
2569
+ target: {
2570
+ deviceId: job.captured.deviceId ?? "sim-device",
2571
+ peripheralId: job.captured.peripheralId ?? "sim-peripheral"
2572
+ }
2573
+ };
2574
+ const outcome = as === "queued" ? { status: "queued" } : job.outcome;
2575
+ if (outcome.status === "needs_attention") {
2576
+ return {
2577
+ ...base,
2578
+ status: "needs_attention",
2579
+ attention: {
2580
+ reason: outcome.reason,
2581
+ outcome: outcome.outcome,
2582
+ message: outcome.message ?? "Delivery stopped."
2583
+ }
2584
+ };
2585
+ }
2586
+ if (outcome.status === "sent_to_printer") {
2587
+ return { ...base, status: "sent_to_printer", sentToPrinterAt: createdAt };
2588
+ }
2589
+ return { ...base, status: "queued" };
2590
+ }
2591
+ function handleGetDurable(jobId) {
2592
+ if (!state.edgeDurableJobs.has(jobId)) return edgeFailure("Print job not found", 404);
2593
+ return success(durableView(jobId));
2594
+ }
2448
2595
  async function handleShowDisplay(request) {
2449
2596
  const body = await readJsonBody(request);
2450
2597
  const captured = {
@@ -2494,6 +2641,13 @@ async function handleEdgeRequest(request, subPath) {
2494
2641
  if (subPath === "/print" && method === "POST") {
2495
2642
  return handlePrint(request);
2496
2643
  }
2644
+ if (subPath === "/print-jobs" && method === "POST") {
2645
+ return handleEnqueue(request);
2646
+ }
2647
+ const durableMatch = subPath.match(/^\/print-jobs\/([^/]+)$/);
2648
+ if (durableMatch && method === "GET") {
2649
+ return handleGetDurable(decodeURIComponent(durableMatch[1]));
2650
+ }
2497
2651
  if (subPath === "/capabilities" && method === "GET") {
2498
2652
  return handleCapabilities(request);
2499
2653
  }
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.25.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",