@siglume/direct-request-payment 0.4.23 → 0.4.24

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.
@@ -1,5 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import type { Request } from "express";
4
4
 
5
5
  import type { SiglumeCheckoutAttempt, SiglumeSdrpOrderStore } from "./siglume-sdrp-routes.js";
@@ -60,6 +60,11 @@ interface SqlParts {
60
60
  readonly qReviews: string;
61
61
  }
62
62
 
63
+ const CHECKOUT_CREATION_LEASE_MS = 30_000;
64
+ const CHECKOUT_CREATION_WAIT_MS = 10_000;
65
+ const CHECKOUT_CREATION_POLL_MS = 100;
66
+ const WEBHOOK_PROCESSING_STALE_MS = 10 * 60 * 1000;
67
+
63
68
  export function createSqlSiglumeOrderStore(options: SiglumeSqlOrderStoreOptions): SiglumeSdrpOrderStore {
64
69
  return new SqlSiglumeOrderStore(normalizeOptions(options));
65
70
  }
@@ -134,22 +139,32 @@ CREATE TABLE IF NOT EXISTS ${parts.qOrders} (
134
139
 
135
140
  statements.push(`
136
141
  CREATE TABLE IF NOT EXISTS ${parts.qAttempts} (
137
- order_id ${text} PRIMARY KEY,
138
- attempt_id ${text} NOT NULL UNIQUE,
142
+ attempt_id ${text} PRIMARY KEY,
143
+ order_id ${text} NOT NULL,
144
+ attempt_number INTEGER NOT NULL,
139
145
  stable_nonce ${text} NOT NULL UNIQUE,
146
+ active_key ${text} UNIQUE,
140
147
  status ${text} NOT NULL DEFAULT 'created',
141
148
  challenge_hash ${text} UNIQUE,
142
149
  checkout_session_id ${text},
143
150
  checkout_url ${text},
151
+ expires_at ${timestamp},
152
+ cancelled_at ${timestamp},
153
+ failed_at ${timestamp},
154
+ creation_owner_id ${text},
155
+ creation_lease_expires_at ${timestamp},
156
+ error_message ${text},
144
157
  requirement_id ${text},
145
158
  chain_receipt_id ${text},
146
159
  pricing_band ${text},
147
160
  paid_at ${timestamp},
148
161
  fulfilled_unsettled_at ${timestamp},
149
162
  created_at ${timestamp} NOT NULL DEFAULT ${now},
150
- updated_at ${timestamp} NOT NULL DEFAULT ${now}
163
+ updated_at ${timestamp} NOT NULL DEFAULT ${now},
164
+ UNIQUE (order_id, attempt_number)
151
165
  )`.trim());
152
166
  if (normalized.dialect !== "mysql") {
167
+ statements.push(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier("idx_siglume_checkout_attempts_order", normalized.dialect)} ON ${parts.qAttempts} (order_id)`);
153
168
  statements.push(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier("idx_siglume_checkout_attempts_challenge", normalized.dialect)} ON ${parts.qAttempts} (challenge_hash)`);
154
169
  }
155
170
 
@@ -278,30 +293,69 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
278
293
 
279
294
  async beginCheckoutAttempt(orderId: string, req: Request): Promise<SiglumeCheckoutAttempt | null> {
280
295
  const cleanOrderId = requireText(orderId, "order_id");
281
- return this.withTransaction(async () => {
282
- const order = await this.findProductOrder(cleanOrderId);
283
- if (!order) return null;
284
- if (this.options.authorize_order && !(await this.options.authorize_order(order, req))) return null;
285
-
286
- const parts = sqlParts(this.options);
287
- const attempt = stableAttempt(cleanOrderId);
288
- await this.insertAttemptIfMissing(cleanOrderId, attempt.attempt_id, attempt.stable_nonce);
289
- const rows = await this.executor().query<Record<string, unknown>>(
290
- `SELECT attempt_id, stable_nonce, checkout_session_id, checkout_url FROM ${parts.qAttempts} WHERE order_id = ${this.p(1)} LIMIT 1`,
291
- [cleanOrderId],
292
- );
293
- const state = rows[0] ?? {};
294
- return {
295
- id: String(order.id),
296
- order_id: String(order.id),
297
- amount_minor: Number(order.amount_minor),
298
- currency: String(order.currency),
299
- attempt_id: String(state.attempt_id || attempt.attempt_id),
300
- stable_nonce: String(state.stable_nonce || attempt.stable_nonce),
301
- checkout_session_id: textOrUndefined(state.checkout_session_id),
302
- checkout_url: textOrUndefined(state.checkout_url),
303
- };
304
- });
296
+ const waitUntil = Date.now() + CHECKOUT_CREATION_WAIT_MS;
297
+ for (;;) {
298
+ const result = await this.withTransaction(async (executor) => {
299
+ const order = await this.findProductOrder(cleanOrderId);
300
+ if (!order) return { done: true, attempt: null as SiglumeCheckoutAttempt | null };
301
+ if (this.options.authorize_order && !(await this.options.authorize_order(order, req))) {
302
+ return { done: true, attempt: null as SiglumeCheckoutAttempt | null };
303
+ }
304
+
305
+ const active = await this.findActiveCheckoutAttempt(executor, cleanOrderId);
306
+ if (active && isReusableCheckoutAttempt(active)) {
307
+ return { done: true, attempt: this.toCheckoutAttempt(order, active) };
308
+ }
309
+ if (active && isCreatingCheckoutAttempt(active) && !timestampHasPassed(active.creation_lease_expires_at)) {
310
+ return { done: false, attempt: this.toCheckoutAttempt(order, active, true) };
311
+ }
312
+ if (active) {
313
+ await this.releaseInactiveAttempt(executor, active, active.status === "pending" ? "expired" : "failed");
314
+ }
315
+
316
+ const attemptNumber = await this.nextAttemptNumber(executor, cleanOrderId);
317
+ const attempt = stableAttempt(cleanOrderId, attemptNumber);
318
+ const inserted = await this.executeChangedWith(
319
+ executor,
320
+ insertAttemptSql(this.options),
321
+ [
322
+ cleanOrderId,
323
+ attemptNumber,
324
+ attempt.attempt_id,
325
+ attempt.stable_nonce,
326
+ cleanOrderId,
327
+ "creating",
328
+ `sdrp_create_${randomUUID()}`,
329
+ sqlTimestamp(Date.now() + CHECKOUT_CREATION_LEASE_MS),
330
+ ],
331
+ );
332
+ if (inserted === 0) {
333
+ return { done: false, attempt: this.toCheckoutAttempt(order, {
334
+ order_id: cleanOrderId,
335
+ attempt_number: attemptNumber,
336
+ attempt_id: attempt.attempt_id,
337
+ stable_nonce: attempt.stable_nonce,
338
+ status: "creating",
339
+ }, true) };
340
+ }
341
+ return {
342
+ done: true,
343
+ attempt: {
344
+ id: String(order.id),
345
+ order_id: String(order.id),
346
+ amount_minor: Number(order.amount_minor),
347
+ currency: String(order.currency),
348
+ attempt_number: attemptNumber,
349
+ attempt_id: attempt.attempt_id,
350
+ stable_nonce: attempt.stable_nonce,
351
+ status: "creating",
352
+ },
353
+ };
354
+ });
355
+ if (result.done) return result.attempt;
356
+ if (Date.now() >= waitUntil) return result.attempt;
357
+ await sleep(CHECKOUT_CREATION_POLL_MS);
358
+ }
305
359
  }
306
360
 
307
361
  async markCheckoutPending(input: {
@@ -311,14 +365,33 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
311
365
  challenge_hash: string;
312
366
  checkout_session_id: string;
313
367
  checkout_url: string;
368
+ expires_at?: string | null;
314
369
  }): Promise<void> {
315
370
  const parts = sqlParts(this.options);
316
371
  await this.executor().execute(
317
372
  `UPDATE ${parts.qAttempts}
318
- SET status = ${this.p(1)}, attempt_id = ${this.p(2)}, stable_nonce = ${this.p(3)}, challenge_hash = ${this.p(4)},
319
- checkout_session_id = ${this.p(5)}, checkout_url = ${this.p(6)}, updated_at = CURRENT_TIMESTAMP
320
- WHERE order_id = ${this.p(7)}`,
321
- ["pending", input.attempt_id, input.stable_nonce, input.challenge_hash, input.checkout_session_id, input.checkout_url, input.order_id],
373
+ SET status = ${this.p(1)}, stable_nonce = ${this.p(2)}, challenge_hash = ${this.p(3)},
374
+ checkout_session_id = ${this.p(4)}, checkout_url = ${this.p(5)}, expires_at = ${this.p(6)},
375
+ creation_owner_id = NULL, creation_lease_expires_at = NULL, error_message = NULL,
376
+ updated_at = CURRENT_TIMESTAMP
377
+ WHERE order_id = ${this.p(7)} AND attempt_id = ${this.p(8)} AND status = ${this.p(9)}`,
378
+ ["pending", input.stable_nonce, input.challenge_hash, input.checkout_session_id, input.checkout_url, sqlTimestampOrNull(input.expires_at), input.order_id, input.attempt_id, "creating"],
379
+ );
380
+ }
381
+
382
+ async markCheckoutFailed(input: {
383
+ order_id: string;
384
+ attempt_id: string;
385
+ error_message?: string;
386
+ }): Promise<void> {
387
+ const parts = sqlParts(this.options);
388
+ await this.executor().execute(
389
+ `UPDATE ${parts.qAttempts}
390
+ SET status = ${this.p(1)}, active_key = NULL, failed_at = CURRENT_TIMESTAMP,
391
+ creation_owner_id = NULL, creation_lease_expires_at = NULL,
392
+ error_message = ${this.p(2)}, updated_at = CURRENT_TIMESTAMP
393
+ WHERE order_id = ${this.p(3)} AND attempt_id = ${this.p(4)} AND status = ${this.p(5)}`,
394
+ ["failed", textOrNull(input.error_message), input.order_id, input.attempt_id, "creating"],
322
395
  );
323
396
  }
324
397
 
@@ -329,13 +402,14 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
329
402
  }
330
403
  return this.withTransaction(async (executor) => {
331
404
  const parts = sqlParts(this.options);
332
- const existing = await executor.query<{ status?: unknown }>(
333
- `SELECT status FROM ${parts.qEvents} WHERE event_id = ${this.p(1)} LIMIT 1`,
405
+ const existing = await executor.query<{ status?: unknown; created_at?: unknown }>(
406
+ `SELECT status, created_at FROM ${parts.qEvents} WHERE event_id = ${this.p(1)} LIMIT 1`,
334
407
  [cleanEventId],
335
408
  );
336
409
  const existingStatus = existing.length ? String(existing[0]?.status || "") : "";
337
- if (existingStatus === "processed" || existingStatus === "processing") return "duplicate";
338
- if (existingStatus === "failed") {
410
+ if (existingStatus === "processed") return "duplicate";
411
+ if (existingStatus === "processing" && !webhookProcessingIsStale(existing[0]?.created_at)) return "duplicate";
412
+ if (existingStatus === "failed" || existingStatus === "processing") {
339
413
  await executor.execute(
340
414
  `UPDATE ${parts.qEvents} SET status = ${this.p(1)}, error_message = NULL, processed_at = NULL WHERE event_id = ${this.p(2)}`,
341
415
  ["processing", cleanEventId],
@@ -367,13 +441,14 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
367
441
  handler: () => Promise<void>,
368
442
  ): Promise<"processed" | "duplicate"> {
369
443
  const parts = sqlParts(this.options);
370
- const existing = await this.options.executor.query<{ status?: unknown }>(
371
- `SELECT status FROM ${parts.qEvents} WHERE event_id = ${this.p(1)} LIMIT 1`,
444
+ const existing = await this.options.executor.query<{ status?: unknown; created_at?: unknown }>(
445
+ `SELECT status, created_at FROM ${parts.qEvents} WHERE event_id = ${this.p(1)} LIMIT 1`,
372
446
  [cleanEventId],
373
447
  );
374
448
  const existingStatus = existing.length ? String(existing[0]?.status || "") : "";
375
- if (existingStatus === "processed" || existingStatus === "processing") return "duplicate";
376
- if (existingStatus === "failed") {
449
+ if (existingStatus === "processed") return "duplicate";
450
+ if (existingStatus === "processing" && !webhookProcessingIsStale(existing[0]?.created_at)) return "duplicate";
451
+ if (existingStatus === "failed" || existingStatus === "processing") {
377
452
  await this.options.executor.execute(
378
453
  `UPDATE ${parts.qEvents} SET status = ${this.p(1)}, error_message = NULL, processed_at = NULL WHERE event_id = ${this.p(2)}`,
379
454
  ["processing", cleanEventId],
@@ -407,9 +482,9 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
407
482
  const parts = sqlParts(this.options);
408
483
  const changed = await this.executeChanged(
409
484
  `UPDATE ${parts.qAttempts}
410
- SET status = ${this.p(1)}, requirement_id = ${this.p(2)}, chain_receipt_id = ${this.p(3)}, paid_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
411
- WHERE order_id = ${this.p(4)} AND status <> ${this.p(5)}`,
412
- ["paid", input.requirement_id, input.chain_receipt_id, input.order_id, "paid"],
485
+ SET status = ${this.p(1)}, active_key = NULL, requirement_id = ${this.p(2)}, chain_receipt_id = ${this.p(3)}, paid_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
486
+ WHERE order_id = ${this.p(4)} AND status NOT IN (${this.p(5)}, ${this.p(6)}, ${this.p(7)}, ${this.p(8)})`,
487
+ ["paid", input.requirement_id, input.chain_receipt_id, input.order_id, "paid", "expired", "cancelled", "failed"],
413
488
  );
414
489
  if (changed && parts.qOrderStatus) {
415
490
  const updatedAt = parts.qOrderUpdatedAt ? `, ${parts.qOrderUpdatedAt} = CURRENT_TIMESTAMP` : "";
@@ -428,9 +503,9 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
428
503
  const parts = sqlParts(this.options);
429
504
  const changed = await this.executeChanged(
430
505
  `UPDATE ${parts.qAttempts}
431
- SET status = ${this.p(1)}, requirement_id = ${this.p(2)}, pricing_band = ${this.p(3)}, fulfilled_unsettled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
432
- WHERE order_id = ${this.p(4)} AND status NOT IN (${this.p(5)}, ${this.p(6)})`,
433
- ["fulfilled_unsettled", input.requirement_id, input.pricing_band, input.order_id, "fulfilled_unsettled", "paid"],
506
+ SET status = ${this.p(1)}, active_key = NULL, requirement_id = ${this.p(2)}, pricing_band = ${this.p(3)}, fulfilled_unsettled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
507
+ WHERE order_id = ${this.p(4)} AND status NOT IN (${this.p(5)}, ${this.p(6)}, ${this.p(7)}, ${this.p(8)}, ${this.p(9)})`,
508
+ ["fulfilled_unsettled", input.requirement_id, input.pricing_band, input.order_id, "fulfilled_unsettled", "paid", "expired", "cancelled", "failed"],
434
509
  );
435
510
  if (changed && parts.qOrderStatus) {
436
511
  const updatedAt = parts.qOrderUpdatedAt ? `, ${parts.qOrderUpdatedAt} = CURRENT_TIMESTAMP` : "";
@@ -467,8 +542,61 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
467
542
  return rows[0] ?? null;
468
543
  }
469
544
 
470
- private async insertAttemptIfMissing(orderId: string, attemptId: string, stableNonce: string): Promise<void> {
471
- await this.executor().execute(insertAttemptSql(this.options), [orderId, attemptId, stableNonce, "created"]);
545
+ private async findActiveCheckoutAttempt(executor: SiglumeSqlExecutor, orderId: string): Promise<Record<string, unknown> | null> {
546
+ const parts = sqlParts(this.options);
547
+ const rows = await executor.query<Record<string, unknown>>(
548
+ `SELECT order_id, attempt_number, attempt_id, stable_nonce, status, checkout_session_id, checkout_url, expires_at,
549
+ creation_lease_expires_at
550
+ FROM ${parts.qAttempts}
551
+ WHERE active_key = ${this.p(1)}
552
+ LIMIT 1`,
553
+ [orderId],
554
+ );
555
+ return rows[0] ?? null;
556
+ }
557
+
558
+ private async nextAttemptNumber(executor: SiglumeSqlExecutor, orderId: string): Promise<number> {
559
+ const parts = sqlParts(this.options);
560
+ const rows = await executor.query<Record<string, unknown>>(
561
+ `SELECT MAX(attempt_number) AS max_attempt_number FROM ${parts.qAttempts} WHERE order_id = ${this.p(1)}`,
562
+ [orderId],
563
+ );
564
+ const current = Number(rows[0]?.max_attempt_number || 0);
565
+ return Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
566
+ }
567
+
568
+ private async releaseInactiveAttempt(executor: SiglumeSqlExecutor, attempt: Record<string, unknown>, status: "expired" | "failed"): Promise<void> {
569
+ const parts = sqlParts(this.options);
570
+ const timestampColumn = status === "expired" ? "expires_at" : "failed_at";
571
+ await executor.execute(
572
+ `UPDATE ${parts.qAttempts}
573
+ SET status = ${this.p(1)}, active_key = NULL, ${quoteIdentifier(timestampColumn, this.options.dialect)} = COALESCE(${quoteIdentifier(timestampColumn, this.options.dialect)}, CURRENT_TIMESTAMP),
574
+ creation_owner_id = NULL, creation_lease_expires_at = NULL, updated_at = CURRENT_TIMESTAMP
575
+ WHERE attempt_id = ${this.p(2)}`,
576
+ [status, attempt.attempt_id],
577
+ );
578
+ }
579
+
580
+ private toCheckoutAttempt(order: Record<string, unknown>, state: Record<string, unknown>, pending = false): SiglumeCheckoutAttempt {
581
+ return {
582
+ id: String(order.id),
583
+ order_id: String(order.id),
584
+ amount_minor: Number(order.amount_minor),
585
+ currency: String(order.currency),
586
+ attempt_number: Number(state.attempt_number || 1),
587
+ attempt_id: String(state.attempt_id),
588
+ stable_nonce: String(state.stable_nonce),
589
+ status: String(state.status || ""),
590
+ checkout_session_id: textOrUndefined(state.checkout_session_id),
591
+ checkout_url: textOrUndefined(state.checkout_url),
592
+ expires_at: timestampTextOrNull(state.expires_at),
593
+ checkout_creation_pending: pending,
594
+ };
595
+ }
596
+
597
+ private async executeChangedWith(executor: SiglumeSqlExecutor, statement: string, params: readonly unknown[]): Promise<number | null> {
598
+ const result = await executor.execute(statement, params);
599
+ return affectedRows(result);
472
600
  }
473
601
 
474
602
  private async executeChanged(statement: string, params: readonly unknown[]): Promise<number | null> {
@@ -528,9 +656,12 @@ function sqlParts(options: NormalizedOptions): SqlParts {
528
656
  function insertAttemptSql(options: NormalizedOptions): string {
529
657
  const parts = sqlParts(options);
530
658
  if (options.dialect === "mysql") {
531
- return `INSERT IGNORE INTO ${parts.qAttempts} (order_id, attempt_id, stable_nonce, status) VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, ${placeholder(3, options)}, ${placeholder(4, options)})`;
659
+ return `INSERT IGNORE INTO ${parts.qAttempts} (order_id, attempt_number, attempt_id, stable_nonce, active_key, status, creation_owner_id, creation_lease_expires_at)
660
+ VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, ${placeholder(3, options)}, ${placeholder(4, options)}, ${placeholder(5, options)}, ${placeholder(6, options)}, ${placeholder(7, options)}, ${placeholder(8, options)})`;
532
661
  }
533
- return `INSERT INTO ${parts.qAttempts} (order_id, attempt_id, stable_nonce, status) VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, ${placeholder(3, options)}, ${placeholder(4, options)}) ON CONFLICT (order_id) DO NOTHING`;
662
+ return `INSERT INTO ${parts.qAttempts} (order_id, attempt_number, attempt_id, stable_nonce, active_key, status, creation_owner_id, creation_lease_expires_at)
663
+ VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, ${placeholder(3, options)}, ${placeholder(4, options)}, ${placeholder(5, options)}, ${placeholder(6, options)}, ${placeholder(7, options)}, ${placeholder(8, options)})
664
+ ON CONFLICT (active_key) DO NOTHING`;
534
665
  }
535
666
 
536
667
  function insertWebhookEventSql(options: NormalizedOptions, _eventId: string): string {
@@ -555,8 +686,8 @@ function quoteIdentifier(identifier: string, dialect: SiglumeSqlDialect): string
555
686
  }).join(".");
556
687
  }
557
688
 
558
- function stableAttempt(orderId: string): { attempt_id: string; stable_nonce: string } {
559
- const digest = hash(orderId).slice(0, 32);
689
+ function stableAttempt(orderId: string, attemptNumber: number): { attempt_id: string; stable_nonce: string } {
690
+ const digest = hash(`${orderId}:${attemptNumber}`).slice(0, 32);
560
691
  return {
561
692
  attempt_id: `sdrp_attempt_${digest}`,
562
693
  stable_nonce: `sdrp-${digest}`,
@@ -586,6 +717,52 @@ function errorMessage(error: unknown): string {
586
717
  return String(error || "webhook handler failed").slice(0, 1000);
587
718
  }
588
719
 
720
+ function webhookProcessingIsStale(value: unknown): boolean {
721
+ if (!value) return false;
722
+ const timestamp = Date.parse(String(value));
723
+ return Number.isFinite(timestamp) && Date.now() - timestamp > WEBHOOK_PROCESSING_STALE_MS;
724
+ }
725
+
726
+ function isReusableCheckoutAttempt(state: Record<string, unknown>): boolean {
727
+ return String(state.status || "") === "pending"
728
+ && Boolean(textOrUndefined(state.checkout_session_id))
729
+ && Boolean(textOrUndefined(state.checkout_url))
730
+ && !timestampHasPassed(state.expires_at);
731
+ }
732
+
733
+ function isCreatingCheckoutAttempt(state: Record<string, unknown>): boolean {
734
+ return String(state.status || "") === "creating";
735
+ }
736
+
737
+ function timestampHasPassed(value: unknown): boolean {
738
+ if (!value) return false;
739
+ const timestamp = Date.parse(String(value));
740
+ return Number.isFinite(timestamp) && timestamp <= Date.now();
741
+ }
742
+
743
+ function sqlTimestamp(value: number | string | Date): string {
744
+ const date = value instanceof Date ? value : new Date(value);
745
+ const pad = (item: number) => String(item).padStart(2, "0");
746
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
747
+ }
748
+
749
+ function sqlTimestampOrNull(value: unknown): string | null {
750
+ if (!value) return null;
751
+ const timestamp = Date.parse(String(value));
752
+ if (!Number.isFinite(timestamp)) return null;
753
+ return sqlTimestamp(timestamp);
754
+ }
755
+
756
+ function timestampTextOrNull(value: unknown): string | null {
757
+ if (!value) return null;
758
+ if (value instanceof Date) return value.toISOString();
759
+ return String(value);
760
+ }
761
+
762
+ function sleep(ms: number): Promise<void> {
763
+ return new Promise((resolve) => setTimeout(resolve, ms));
764
+ }
765
+
589
766
  function normalizeRows(value: unknown): Record<string, unknown>[] {
590
767
  if (Array.isArray(value)) return value as Record<string, unknown>[];
591
768
  if (value && typeof value === "object" && Array.isArray((value as { rows?: unknown[] }).rows)) {
@@ -15,10 +15,14 @@ export interface SiglumeCheckoutOrder {
15
15
 
16
16
  export interface SiglumeCheckoutAttempt extends SiglumeCheckoutOrder {
17
17
  order_id: string;
18
+ attempt_number?: number;
18
19
  attempt_id: string;
19
20
  stable_nonce: string;
21
+ status?: string;
20
22
  checkout_url?: string;
21
23
  checkout_session_id?: string;
24
+ expires_at?: string | null;
25
+ checkout_creation_pending?: boolean;
22
26
  }
23
27
 
24
28
  export interface SiglumeSdrpOrderStore {
@@ -30,6 +34,12 @@ export interface SiglumeSdrpOrderStore {
30
34
  challenge_hash: string;
31
35
  checkout_session_id: string;
32
36
  checkout_url: string;
37
+ expires_at?: string | null;
38
+ }): Promise<void>;
39
+ markCheckoutFailed?(input: {
40
+ order_id: string;
41
+ attempt_id: string;
42
+ error_message?: string;
33
43
  }): Promise<void>;
34
44
  processWebhookEventOnce(
35
45
  eventId: string,
@@ -78,20 +88,35 @@ export function createSiglumeSdrpCheckoutRouter(options: SiglumeSdrpRouterOption
78
88
  return;
79
89
  }
80
90
 
91
+ if (attempt.checkout_creation_pending) {
92
+ res.status(202).json({ status: "creating", retry_after_ms: 250 });
93
+ return;
94
+ }
95
+
81
96
  if (attempt.checkout_url && attempt.checkout_session_id) {
82
97
  res.json({ checkout_url: attempt.checkout_url, session_id: attempt.checkout_session_id });
83
98
  return;
84
99
  }
85
100
 
86
- const session = await merchant.createCheckoutSession({
87
- merchant: options.merchant,
88
- amount_minor: attempt.amount_minor,
89
- currency: attempt.currency,
90
- nonce: attempt.stable_nonce,
91
- success_url: `${options.shop_public_origin}/checkout/siglume/success`,
92
- cancel_url: `${options.shop_public_origin}/checkout/siglume/cancel`,
93
- metadata: { order_id: attempt.order_id, attempt_id: attempt.attempt_id },
94
- });
101
+ let session;
102
+ try {
103
+ session = await merchant.createCheckoutSession({
104
+ merchant: options.merchant,
105
+ amount_minor: attempt.amount_minor,
106
+ currency: attempt.currency,
107
+ nonce: attempt.stable_nonce,
108
+ success_url: `${options.shop_public_origin}/checkout/siglume/success`,
109
+ cancel_url: `${options.shop_public_origin}/checkout/siglume/cancel`,
110
+ metadata: { order_id: attempt.order_id, attempt_id: attempt.attempt_id },
111
+ });
112
+ } catch (error) {
113
+ await options.order_store.markCheckoutFailed?.({
114
+ order_id: attempt.order_id,
115
+ attempt_id: attempt.attempt_id,
116
+ error_message: error instanceof Error ? error.message : "checkout session creation failed",
117
+ });
118
+ throw error;
119
+ }
95
120
 
96
121
  await options.order_store.markCheckoutPending({
97
122
  order_id: attempt.order_id,
@@ -100,6 +125,7 @@ export function createSiglumeSdrpCheckoutRouter(options: SiglumeSdrpRouterOption
100
125
  challenge_hash: session.challenge_hash,
101
126
  checkout_session_id: session.session_id,
102
127
  checkout_url: session.checkout_url,
128
+ expires_at: session.expires_at ?? null,
103
129
  });
104
130
 
105
131
  res.json({ checkout_url: session.checkout_url, session_id: session.session_id });
@@ -161,6 +187,9 @@ async function processSiglumeWebhookEvent(
161
187
  if (event.type !== "direct_payment.confirmed") {
162
188
  return;
163
189
  }
190
+ if (isReadinessProbeEvent(event)) {
191
+ return;
192
+ }
164
193
 
165
194
  const confirmation = classifyDirectPaymentConfirmation(event);
166
195
 
@@ -222,6 +251,11 @@ async function processSiglumeWebhookEvent(
222
251
  });
223
252
  }
224
253
 
254
+ function isReadinessProbeEvent(event: Awaited<ReturnType<typeof verifyDirectRequestPaymentWebhook>>["event"]): boolean {
255
+ const data = event.data as Record<string, unknown> | undefined;
256
+ return data?.readiness_probe === true || data?.mode === "readiness_probe";
257
+ }
258
+
225
259
  function isStandardCheckoutAmount(currency: string, amountMinor: number): boolean {
226
260
  if (!Number.isSafeInteger(amountMinor)) return false;
227
261
  const normalizedCurrency = String(currency || "").toUpperCase();
@@ -19,7 +19,15 @@ app = FastAPI()
19
19
  engine = create_sqlalchemy_engine(os.environ["DATABASE_URL"])
20
20
  create_sqlalchemy_siglume_schema(engine)
21
21
  SessionLocal = sessionmaker(engine, future=True)
22
- siglume_order_store = SQLAlchemySiglumeOrderStore(SessionLocal)
22
+ siglume_order_store = SQLAlchemySiglumeOrderStore(
23
+ SessionLocal,
24
+ # Optional: connect to an existing product orders table/columns.
25
+ # orders_table=product_orders,
26
+ # order_id_column="order_id",
27
+ # amount_minor_column="total_cents",
28
+ # currency_column="iso_currency",
29
+ # order_status_column="payment_status",
30
+ )
23
31
 
24
32
  app.include_router(
25
33
  create_siglume_sdrp_router(siglume_order_store, allow_metered_payments=False),
@@ -28,8 +36,29 @@ app.include_router(
28
36
  ```
29
37
 
30
38
  Use `siglume_order_store_sqlalchemy.py` for a durable SQLAlchemy adapter. It
31
- creates the required checkout attempt, webhook event, and payment review tables
32
- and keeps webhook processing transactional.
39
+ creates only the SDRP checkout attempt, webhook event, and payment review
40
+ tables by default and keeps webhook processing transactional. Pass
41
+ `include_orders_table=True` to `create_sqlalchemy_siglume_schema()` only for the
42
+ sample `orders` table; existing products should use their own order table.
43
+
44
+ For async SQLAlchemy projects, use `siglume_order_store_sqlalchemy_async.py`
45
+ instead:
46
+
47
+ ```py
48
+ from sqlalchemy.ext.asyncio import async_sessionmaker
49
+
50
+ from .siglume.siglume_order_store_sqlalchemy_async import (
51
+ AsyncSQLAlchemySiglumeOrderStore,
52
+ create_async_sqlalchemy_engine,
53
+ create_async_sqlalchemy_siglume_schema,
54
+ )
55
+
56
+ engine = create_async_sqlalchemy_engine(os.environ["DATABASE_URL"])
57
+ # Run this during your FastAPI startup/lifespan initialization.
58
+ await create_async_sqlalchemy_siglume_schema(engine)
59
+ SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
60
+ siglume_order_store = AsyncSQLAlchemySiglumeOrderStore(SessionLocal)
61
+ ```
33
62
 
34
63
  Keep `process_webhook_event_once()` transactional: record the webhook event as
35
64
  processed only after the order update or review write succeeds. The generated
@@ -33,6 +33,7 @@ class ExampleSiglumeOrderStore:
33
33
  challenge_hash: str,
34
34
  checkout_session_id: str,
35
35
  checkout_url: str,
36
+ expires_at: str | None = None,
36
37
  ) -> None:
37
38
  order = _orders.get(order_id)
38
39
  if not order:
@@ -43,6 +44,20 @@ class ExampleSiglumeOrderStore:
43
44
  order["challenge_hash"] = challenge_hash
44
45
  order["checkout_session_id"] = checkout_session_id
45
46
  order["checkout_url"] = checkout_url
47
+ order["expires_at"] = expires_at
48
+
49
+ async def mark_checkout_failed(
50
+ self,
51
+ *,
52
+ order_id: str,
53
+ attempt_id: str,
54
+ error_message: str | None = None,
55
+ ) -> None:
56
+ order = _orders.get(order_id)
57
+ if not order or order.get("attempt_id") != attempt_id:
58
+ return
59
+ order["status"] = "failed"
60
+ order["checkout_error"] = error_message or "checkout session creation failed"
46
61
 
47
62
  async def process_webhook_event_once(self, event_id: str, handler) -> str:
48
63
  if event_id in _processed_events: