@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.
- package/CHANGELOG.md +24 -0
- package/README.md +23 -12
- package/bin/siglume-sdrp.mjs +244 -9
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/docs/announcement-ja.md +7 -3
- package/docs/api-reference.md +5 -3
- package/docs/merchant-quickstart.md +6 -4
- package/docs/pricing.md +1 -1
- package/docs/quickstart-10-minutes.md +91 -58
- package/docs/sandbox.md +23 -5
- package/docs/troubleshooting.md +12 -8
- package/examples/hosted-checkout-python/pyproject.toml +1 -1
- package/package.json +1 -1
- package/templates/express/siglume-order-store.sql.ts +229 -52
- package/templates/express/siglume-sdrp-routes.ts +43 -9
- package/templates/fastapi/README.md +32 -3
- package/templates/fastapi/siglume_order_store_example.py +15 -0
- package/templates/fastapi/siglume_order_store_sqlalchemy.py +238 -53
- package/templates/fastapi/siglume_order_store_sqlalchemy_async.py +496 -0
- package/templates/fastapi/siglume_sdrp_routes.py +31 -0
|
@@ -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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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)},
|
|
319
|
-
checkout_session_id = ${this.p(
|
|
320
|
-
|
|
321
|
-
|
|
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"
|
|
338
|
-
if (existingStatus === "
|
|
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"
|
|
376
|
-
if (existingStatus === "
|
|
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
|
|
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
|
|
471
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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
|
|
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:
|