@siglume/direct-request-payment 0.4.23 → 0.4.25

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,17 @@ interface SqlParts {
60
60
  readonly qReviews: string;
61
61
  }
62
62
 
63
+ interface TypeOrmQueryRunner {
64
+ connect?: () => Promise<void>;
65
+ query?: (statement: string, params?: readonly unknown[], useStructuredResult?: boolean) => Promise<unknown>;
66
+ release?: () => Promise<void>;
67
+ }
68
+
69
+ const CHECKOUT_CREATION_LEASE_MS = 30_000;
70
+ const CHECKOUT_CREATION_WAIT_MS = 10_000;
71
+ const CHECKOUT_CREATION_POLL_MS = 100;
72
+ const WEBHOOK_PROCESSING_STALE_MS = 10 * 60 * 1000;
73
+
63
74
  export function createSqlSiglumeOrderStore(options: SiglumeSqlOrderStoreOptions): SiglumeSdrpOrderStore {
64
75
  return new SqlSiglumeOrderStore(normalizeOptions(options));
65
76
  }
@@ -134,22 +145,32 @@ CREATE TABLE IF NOT EXISTS ${parts.qOrders} (
134
145
 
135
146
  statements.push(`
136
147
  CREATE TABLE IF NOT EXISTS ${parts.qAttempts} (
137
- order_id ${text} PRIMARY KEY,
138
- attempt_id ${text} NOT NULL UNIQUE,
148
+ attempt_id ${text} PRIMARY KEY,
149
+ order_id ${text} NOT NULL,
150
+ attempt_number INTEGER NOT NULL,
139
151
  stable_nonce ${text} NOT NULL UNIQUE,
152
+ active_key ${text} UNIQUE,
140
153
  status ${text} NOT NULL DEFAULT 'created',
141
154
  challenge_hash ${text} UNIQUE,
142
155
  checkout_session_id ${text},
143
156
  checkout_url ${text},
157
+ expires_at ${timestamp},
158
+ cancelled_at ${timestamp},
159
+ failed_at ${timestamp},
160
+ creation_owner_id ${text},
161
+ creation_lease_expires_at ${timestamp},
162
+ error_message ${text},
144
163
  requirement_id ${text},
145
164
  chain_receipt_id ${text},
146
165
  pricing_band ${text},
147
166
  paid_at ${timestamp},
148
167
  fulfilled_unsettled_at ${timestamp},
149
168
  created_at ${timestamp} NOT NULL DEFAULT ${now},
150
- updated_at ${timestamp} NOT NULL DEFAULT ${now}
169
+ updated_at ${timestamp} NOT NULL DEFAULT ${now},
170
+ UNIQUE (order_id, attempt_number)
151
171
  )`.trim());
152
172
  if (normalized.dialect !== "mysql") {
173
+ statements.push(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier("idx_siglume_checkout_attempts_order", normalized.dialect)} ON ${parts.qAttempts} (order_id)`);
153
174
  statements.push(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier("idx_siglume_checkout_attempts_challenge", normalized.dialect)} ON ${parts.qAttempts} (challenge_hash)`);
154
175
  }
155
176
 
@@ -201,6 +222,8 @@ export function createPrismaSiglumeSqlExecutor(prisma: unknown): SiglumeSqlExecu
201
222
  export function createTypeOrmSiglumeSqlExecutor(dataSource: unknown): SiglumeSqlExecutor {
202
223
  const source = dataSource as {
203
224
  query?: (statement: string, params?: readonly unknown[]) => Promise<unknown>;
225
+ queryRunner?: TypeOrmQueryRunner | null;
226
+ createQueryRunner?: () => TypeOrmQueryRunner;
204
227
  transaction?: <T>(handler: (manager: { query: (statement: string, params?: readonly unknown[]) => Promise<unknown> }) => Promise<T>) => Promise<T>;
205
228
  };
206
229
  return {
@@ -208,6 +231,15 @@ export function createTypeOrmSiglumeSqlExecutor(dataSource: unknown): SiglumeSql
208
231
  return normalizeRows(await source.query?.(statement, params)) as T[];
209
232
  },
210
233
  async execute(statement, params = []) {
234
+ const runner = source.queryRunner ?? source.createQueryRunner?.();
235
+ if (runner?.query) {
236
+ try {
237
+ if (!source.queryRunner) await runner.connect?.();
238
+ return normalizeTypeOrmExecuteResult(await runner.query(statement, params, true));
239
+ } finally {
240
+ if (!source.queryRunner) await runner.release?.();
241
+ }
242
+ }
211
243
  return source.query?.(statement, params);
212
244
  },
213
245
  async transaction(handler) {
@@ -278,30 +310,69 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
278
310
 
279
311
  async beginCheckoutAttempt(orderId: string, req: Request): Promise<SiglumeCheckoutAttempt | null> {
280
312
  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
- });
313
+ const waitUntil = Date.now() + CHECKOUT_CREATION_WAIT_MS;
314
+ for (;;) {
315
+ const result = await this.withTransaction(async (executor) => {
316
+ const order = await this.findProductOrder(cleanOrderId);
317
+ if (!order) return { done: true, attempt: null as SiglumeCheckoutAttempt | null };
318
+ if (this.options.authorize_order && !(await this.options.authorize_order(order, req))) {
319
+ return { done: true, attempt: null as SiglumeCheckoutAttempt | null };
320
+ }
321
+
322
+ const active = await this.findActiveCheckoutAttempt(executor, cleanOrderId);
323
+ if (active && isReusableCheckoutAttempt(active)) {
324
+ return { done: true, attempt: this.toCheckoutAttempt(order, active) };
325
+ }
326
+ if (active && isCreatingCheckoutAttempt(active) && !timestampHasPassed(active.creation_lease_expires_at)) {
327
+ return { done: false, attempt: this.toCheckoutAttempt(order, active, true) };
328
+ }
329
+ if (active) {
330
+ await this.releaseInactiveAttempt(executor, active, active.status === "pending" ? "expired" : "failed");
331
+ }
332
+
333
+ const attemptNumber = await this.nextAttemptNumber(executor, cleanOrderId);
334
+ const attempt = stableAttempt(cleanOrderId, attemptNumber);
335
+ const inserted = await this.executeChangedWith(
336
+ executor,
337
+ insertAttemptSql(this.options),
338
+ [
339
+ cleanOrderId,
340
+ attemptNumber,
341
+ attempt.attempt_id,
342
+ attempt.stable_nonce,
343
+ cleanOrderId,
344
+ "creating",
345
+ `sdrp_create_${randomUUID()}`,
346
+ sqlTimestamp(Date.now() + CHECKOUT_CREATION_LEASE_MS),
347
+ ],
348
+ );
349
+ if (inserted === 0) {
350
+ return { done: false, attempt: this.toCheckoutAttempt(order, {
351
+ order_id: cleanOrderId,
352
+ attempt_number: attemptNumber,
353
+ attempt_id: attempt.attempt_id,
354
+ stable_nonce: attempt.stable_nonce,
355
+ status: "creating",
356
+ }, true) };
357
+ }
358
+ return {
359
+ done: true,
360
+ attempt: {
361
+ id: String(order.id),
362
+ order_id: String(order.id),
363
+ amount_minor: Number(order.amount_minor),
364
+ currency: String(order.currency),
365
+ attempt_number: attemptNumber,
366
+ attempt_id: attempt.attempt_id,
367
+ stable_nonce: attempt.stable_nonce,
368
+ status: "creating",
369
+ },
370
+ };
371
+ });
372
+ if (result.done) return result.attempt;
373
+ if (Date.now() >= waitUntil) return result.attempt;
374
+ await sleep(CHECKOUT_CREATION_POLL_MS);
375
+ }
305
376
  }
306
377
 
307
378
  async markCheckoutPending(input: {
@@ -311,14 +382,33 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
311
382
  challenge_hash: string;
312
383
  checkout_session_id: string;
313
384
  checkout_url: string;
385
+ expires_at?: string | null;
314
386
  }): Promise<void> {
315
387
  const parts = sqlParts(this.options);
316
388
  await this.executor().execute(
317
389
  `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],
390
+ SET status = ${this.p(1)}, stable_nonce = ${this.p(2)}, challenge_hash = ${this.p(3)},
391
+ checkout_session_id = ${this.p(4)}, checkout_url = ${this.p(5)}, expires_at = ${timestampPlaceholder(6, this.options)},
392
+ creation_owner_id = NULL, creation_lease_expires_at = NULL, error_message = NULL,
393
+ updated_at = CURRENT_TIMESTAMP
394
+ WHERE order_id = ${this.p(7)} AND attempt_id = ${this.p(8)} AND status = ${this.p(9)}`,
395
+ ["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"],
396
+ );
397
+ }
398
+
399
+ async markCheckoutFailed(input: {
400
+ order_id: string;
401
+ attempt_id: string;
402
+ error_message?: string;
403
+ }): Promise<void> {
404
+ const parts = sqlParts(this.options);
405
+ await this.executor().execute(
406
+ `UPDATE ${parts.qAttempts}
407
+ SET status = ${this.p(1)}, active_key = NULL, failed_at = CURRENT_TIMESTAMP,
408
+ creation_owner_id = NULL, creation_lease_expires_at = NULL,
409
+ error_message = ${this.p(2)}, updated_at = CURRENT_TIMESTAMP
410
+ WHERE order_id = ${this.p(3)} AND attempt_id = ${this.p(4)} AND status = ${this.p(5)}`,
411
+ ["failed", textOrNull(input.error_message), input.order_id, input.attempt_id, "creating"],
322
412
  );
323
413
  }
324
414
 
@@ -329,13 +419,14 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
329
419
  }
330
420
  return this.withTransaction(async (executor) => {
331
421
  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`,
422
+ const existing = await executor.query<{ status?: unknown; created_at?: unknown }>(
423
+ `SELECT status, created_at FROM ${parts.qEvents} WHERE event_id = ${this.p(1)} LIMIT 1`,
334
424
  [cleanEventId],
335
425
  );
336
426
  const existingStatus = existing.length ? String(existing[0]?.status || "") : "";
337
- if (existingStatus === "processed" || existingStatus === "processing") return "duplicate";
338
- if (existingStatus === "failed") {
427
+ if (existingStatus === "processed") return "duplicate";
428
+ if (existingStatus === "processing" && !webhookProcessingIsStale(existing[0]?.created_at)) return "duplicate";
429
+ if (existingStatus === "failed" || existingStatus === "processing") {
339
430
  await executor.execute(
340
431
  `UPDATE ${parts.qEvents} SET status = ${this.p(1)}, error_message = NULL, processed_at = NULL WHERE event_id = ${this.p(2)}`,
341
432
  ["processing", cleanEventId],
@@ -367,13 +458,14 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
367
458
  handler: () => Promise<void>,
368
459
  ): Promise<"processed" | "duplicate"> {
369
460
  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`,
461
+ const existing = await this.options.executor.query<{ status?: unknown; created_at?: unknown }>(
462
+ `SELECT status, created_at FROM ${parts.qEvents} WHERE event_id = ${this.p(1)} LIMIT 1`,
372
463
  [cleanEventId],
373
464
  );
374
465
  const existingStatus = existing.length ? String(existing[0]?.status || "") : "";
375
- if (existingStatus === "processed" || existingStatus === "processing") return "duplicate";
376
- if (existingStatus === "failed") {
466
+ if (existingStatus === "processed") return "duplicate";
467
+ if (existingStatus === "processing" && !webhookProcessingIsStale(existing[0]?.created_at)) return "duplicate";
468
+ if (existingStatus === "failed" || existingStatus === "processing") {
377
469
  await this.options.executor.execute(
378
470
  `UPDATE ${parts.qEvents} SET status = ${this.p(1)}, error_message = NULL, processed_at = NULL WHERE event_id = ${this.p(2)}`,
379
471
  ["processing", cleanEventId],
@@ -407,9 +499,9 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
407
499
  const parts = sqlParts(this.options);
408
500
  const changed = await this.executeChanged(
409
501
  `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"],
502
+ 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
503
+ WHERE order_id = ${this.p(4)} AND status NOT IN (${this.p(5)}, ${this.p(6)}, ${this.p(7)}, ${this.p(8)})`,
504
+ ["paid", input.requirement_id, input.chain_receipt_id, input.order_id, "paid", "expired", "cancelled", "failed"],
413
505
  );
414
506
  if (changed && parts.qOrderStatus) {
415
507
  const updatedAt = parts.qOrderUpdatedAt ? `, ${parts.qOrderUpdatedAt} = CURRENT_TIMESTAMP` : "";
@@ -428,9 +520,9 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
428
520
  const parts = sqlParts(this.options);
429
521
  const changed = await this.executeChanged(
430
522
  `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"],
523
+ 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
524
+ 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)})`,
525
+ ["fulfilled_unsettled", input.requirement_id, input.pricing_band, input.order_id, "fulfilled_unsettled", "paid", "expired", "cancelled", "failed"],
434
526
  );
435
527
  if (changed && parts.qOrderStatus) {
436
528
  const updatedAt = parts.qOrderUpdatedAt ? `, ${parts.qOrderUpdatedAt} = CURRENT_TIMESTAMP` : "";
@@ -467,8 +559,61 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
467
559
  return rows[0] ?? null;
468
560
  }
469
561
 
470
- private async insertAttemptIfMissing(orderId: string, attemptId: string, stableNonce: string): Promise<void> {
471
- await this.executor().execute(insertAttemptSql(this.options), [orderId, attemptId, stableNonce, "created"]);
562
+ private async findActiveCheckoutAttempt(executor: SiglumeSqlExecutor, orderId: string): Promise<Record<string, unknown> | null> {
563
+ const parts = sqlParts(this.options);
564
+ const rows = await executor.query<Record<string, unknown>>(
565
+ `SELECT order_id, attempt_number, attempt_id, stable_nonce, status, checkout_session_id, checkout_url, expires_at,
566
+ creation_lease_expires_at
567
+ FROM ${parts.qAttempts}
568
+ WHERE active_key = ${this.p(1)}
569
+ LIMIT 1`,
570
+ [orderId],
571
+ );
572
+ return rows[0] ?? null;
573
+ }
574
+
575
+ private async nextAttemptNumber(executor: SiglumeSqlExecutor, orderId: string): Promise<number> {
576
+ const parts = sqlParts(this.options);
577
+ const rows = await executor.query<Record<string, unknown>>(
578
+ `SELECT MAX(attempt_number) AS max_attempt_number FROM ${parts.qAttempts} WHERE order_id = ${this.p(1)}`,
579
+ [orderId],
580
+ );
581
+ const current = Number(rows[0]?.max_attempt_number || 0);
582
+ return Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
583
+ }
584
+
585
+ private async releaseInactiveAttempt(executor: SiglumeSqlExecutor, attempt: Record<string, unknown>, status: "expired" | "failed"): Promise<void> {
586
+ const parts = sqlParts(this.options);
587
+ const timestampColumn = status === "expired" ? "expires_at" : "failed_at";
588
+ await executor.execute(
589
+ `UPDATE ${parts.qAttempts}
590
+ SET status = ${this.p(1)}, active_key = NULL, ${quoteIdentifier(timestampColumn, this.options.dialect)} = COALESCE(${quoteIdentifier(timestampColumn, this.options.dialect)}, CURRENT_TIMESTAMP),
591
+ creation_owner_id = NULL, creation_lease_expires_at = NULL, updated_at = CURRENT_TIMESTAMP
592
+ WHERE attempt_id = ${this.p(2)}`,
593
+ [status, attempt.attempt_id],
594
+ );
595
+ }
596
+
597
+ private toCheckoutAttempt(order: Record<string, unknown>, state: Record<string, unknown>, pending = false): SiglumeCheckoutAttempt {
598
+ return {
599
+ id: String(order.id),
600
+ order_id: String(order.id),
601
+ amount_minor: Number(order.amount_minor),
602
+ currency: String(order.currency),
603
+ attempt_number: Number(state.attempt_number || 1),
604
+ attempt_id: String(state.attempt_id),
605
+ stable_nonce: String(state.stable_nonce),
606
+ status: String(state.status || ""),
607
+ checkout_session_id: textOrUndefined(state.checkout_session_id),
608
+ checkout_url: textOrUndefined(state.checkout_url),
609
+ expires_at: timestampTextOrNull(state.expires_at),
610
+ checkout_creation_pending: pending,
611
+ };
612
+ }
613
+
614
+ private async executeChangedWith(executor: SiglumeSqlExecutor, statement: string, params: readonly unknown[]): Promise<number | null> {
615
+ const result = await executor.execute(statement, params);
616
+ return affectedRows(result);
472
617
  }
473
618
 
474
619
  private async executeChanged(statement: string, params: readonly unknown[]): Promise<number | null> {
@@ -528,9 +673,12 @@ function sqlParts(options: NormalizedOptions): SqlParts {
528
673
  function insertAttemptSql(options: NormalizedOptions): string {
529
674
  const parts = sqlParts(options);
530
675
  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)})`;
676
+ return `INSERT IGNORE INTO ${parts.qAttempts} (order_id, attempt_number, attempt_id, stable_nonce, active_key, status, creation_owner_id, creation_lease_expires_at)
677
+ 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
678
  }
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`;
679
+ return `INSERT INTO ${parts.qAttempts} (order_id, attempt_number, attempt_id, stable_nonce, active_key, status, creation_owner_id, creation_lease_expires_at)
680
+ VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, ${placeholder(3, options)}, ${placeholder(4, options)}, ${placeholder(5, options)}, ${placeholder(6, options)}, ${placeholder(7, options)}, ${timestampPlaceholder(8, options)})
681
+ ON CONFLICT (active_key) DO NOTHING`;
534
682
  }
535
683
 
536
684
  function insertWebhookEventSql(options: NormalizedOptions, _eventId: string): string {
@@ -545,6 +693,11 @@ function placeholder(index: number, options: NormalizedOptions): string {
545
693
  return options.param_style === "numbered" ? `$${index}` : "?";
546
694
  }
547
695
 
696
+ function timestampPlaceholder(index: number, options: NormalizedOptions): string {
697
+ const value = placeholder(index, options);
698
+ return options.dialect === "postgres" ? `CAST(${value} AS TIMESTAMPTZ)` : value;
699
+ }
700
+
548
701
  function quoteIdentifier(identifier: string, dialect: SiglumeSqlDialect): string {
549
702
  const quote = dialect === "mysql" ? "`" : "\"";
550
703
  return identifier.split(".").map((part) => {
@@ -555,8 +708,8 @@ function quoteIdentifier(identifier: string, dialect: SiglumeSqlDialect): string
555
708
  }).join(".");
556
709
  }
557
710
 
558
- function stableAttempt(orderId: string): { attempt_id: string; stable_nonce: string } {
559
- const digest = hash(orderId).slice(0, 32);
711
+ function stableAttempt(orderId: string, attemptNumber: number): { attempt_id: string; stable_nonce: string } {
712
+ const digest = hash(`${orderId}:${attemptNumber}`).slice(0, 32);
560
713
  return {
561
714
  attempt_id: `sdrp_attempt_${digest}`,
562
715
  stable_nonce: `sdrp-${digest}`,
@@ -586,8 +739,57 @@ function errorMessage(error: unknown): string {
586
739
  return String(error || "webhook handler failed").slice(0, 1000);
587
740
  }
588
741
 
742
+ function webhookProcessingIsStale(value: unknown): boolean {
743
+ if (!value) return false;
744
+ const timestamp = Date.parse(String(value));
745
+ return Number.isFinite(timestamp) && Date.now() - timestamp > WEBHOOK_PROCESSING_STALE_MS;
746
+ }
747
+
748
+ function isReusableCheckoutAttempt(state: Record<string, unknown>): boolean {
749
+ return String(state.status || "") === "pending"
750
+ && Boolean(textOrUndefined(state.checkout_session_id))
751
+ && Boolean(textOrUndefined(state.checkout_url))
752
+ && !timestampHasPassed(state.expires_at);
753
+ }
754
+
755
+ function isCreatingCheckoutAttempt(state: Record<string, unknown>): boolean {
756
+ return String(state.status || "") === "creating";
757
+ }
758
+
759
+ function timestampHasPassed(value: unknown): boolean {
760
+ if (!value) return false;
761
+ const timestamp = Date.parse(String(value));
762
+ return Number.isFinite(timestamp) && timestamp <= Date.now();
763
+ }
764
+
765
+ function sqlTimestamp(value: number | string | Date): string {
766
+ const date = value instanceof Date ? value : new Date(value);
767
+ const pad = (item: number) => String(item).padStart(2, "0");
768
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
769
+ }
770
+
771
+ function sqlTimestampOrNull(value: unknown): string | null {
772
+ if (!value) return null;
773
+ const timestamp = Date.parse(String(value));
774
+ if (!Number.isFinite(timestamp)) return null;
775
+ return sqlTimestamp(timestamp);
776
+ }
777
+
778
+ function timestampTextOrNull(value: unknown): string | null {
779
+ if (!value) return null;
780
+ if (value instanceof Date) return value.toISOString();
781
+ return String(value);
782
+ }
783
+
784
+ function sleep(ms: number): Promise<void> {
785
+ return new Promise((resolve) => setTimeout(resolve, ms));
786
+ }
787
+
589
788
  function normalizeRows(value: unknown): Record<string, unknown>[] {
590
- if (Array.isArray(value)) return value as Record<string, unknown>[];
789
+ if (Array.isArray(value)) {
790
+ if (Array.isArray(value[0])) return value[0] as Record<string, unknown>[];
791
+ return value as Record<string, unknown>[];
792
+ }
591
793
  if (value && typeof value === "object" && Array.isArray((value as { rows?: unknown[] }).rows)) {
592
794
  return (value as { rows: Record<string, unknown>[] }).rows;
593
795
  }
@@ -596,14 +798,30 @@ function normalizeRows(value: unknown): Record<string, unknown>[] {
596
798
 
597
799
  function affectedRows(value: unknown): number | null {
598
800
  if (typeof value === "number") return value;
801
+ if (Array.isArray(value)) {
802
+ for (const item of value) {
803
+ const changed = affectedRows(item);
804
+ if (changed !== null) return changed;
805
+ }
806
+ return null;
807
+ }
599
808
  if (!value || typeof value !== "object") return null;
600
809
  const record = value as Record<string, unknown>;
601
- for (const key of ["rowCount", "affectedRows", "changes"]) {
810
+ for (const key of ["rowCount", "affectedRows", "changes", "affected"]) {
602
811
  if (typeof record[key] === "number") return record[key] as number;
603
812
  }
604
813
  return null;
605
814
  }
606
815
 
816
+ function normalizeTypeOrmExecuteResult(value: unknown): unknown {
817
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
818
+ const record = value as Record<string, unknown>;
819
+ if (typeof record.affected === "number" && typeof record.rowCount !== "number") {
820
+ return { ...record, rowCount: record.affected };
821
+ }
822
+ return value;
823
+ }
824
+
607
825
  function toDrizzleStatement(
608
826
  sqlTag: {
609
827
  (strings: TemplateStringsArray, ...params: unknown[]): unknown;
@@ -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: