@siglume/direct-request-payment 0.4.22 → 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 +36 -0
- package/README.md +23 -12
- package/bin/siglume-sdrp.mjs +302 -19
- 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 +29 -4
- package/docs/troubleshooting.md +12 -8
- package/examples/hosted-checkout-python/pyproject.toml +1 -1
- package/package.json +2 -2
- package/templates/express/siglume-order-store.sql.ts +279 -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,31 +365,59 @@ 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;
|
|
369
|
+
}): Promise<void> {
|
|
370
|
+
const parts = sqlParts(this.options);
|
|
371
|
+
await this.executor().execute(
|
|
372
|
+
`UPDATE ${parts.qAttempts}
|
|
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;
|
|
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)},
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
|
325
398
|
async processWebhookEventOnce(eventId: string, handler: () => Promise<void>): Promise<"processed" | "duplicate"> {
|
|
326
399
|
const cleanEventId = requireText(eventId, "event_id");
|
|
400
|
+
if (!this.options.executor.transaction && !this.tx.getStore()) {
|
|
401
|
+
return this.processWebhookEventOnceWithoutTransaction(cleanEventId, handler);
|
|
402
|
+
}
|
|
327
403
|
return this.withTransaction(async (executor) => {
|
|
328
404
|
const parts = sqlParts(this.options);
|
|
329
|
-
const existing = await executor.query(
|
|
330
|
-
`SELECT
|
|
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`,
|
|
331
407
|
[cleanEventId],
|
|
332
408
|
);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
409
|
+
const existingStatus = existing.length ? String(existing[0]?.status || "") : "";
|
|
410
|
+
if (existingStatus === "processed") return "duplicate";
|
|
411
|
+
if (existingStatus === "processing" && !webhookProcessingIsStale(existing[0]?.created_at)) return "duplicate";
|
|
412
|
+
if (existingStatus === "failed" || existingStatus === "processing") {
|
|
413
|
+
await executor.execute(
|
|
414
|
+
`UPDATE ${parts.qEvents} SET status = ${this.p(1)}, error_message = NULL, processed_at = NULL WHERE event_id = ${this.p(2)}`,
|
|
415
|
+
["processing", cleanEventId],
|
|
416
|
+
);
|
|
417
|
+
} else {
|
|
418
|
+
const inserted = affectedRows(await executor.execute(insertWebhookEventSql(this.options, cleanEventId), [cleanEventId, "processing"]));
|
|
419
|
+
if (inserted === 0) return "duplicate";
|
|
420
|
+
}
|
|
339
421
|
await this.tx.run(executor, handler);
|
|
340
422
|
await executor.execute(
|
|
341
423
|
`UPDATE ${parts.qEvents} SET status = ${this.p(1)}, processed_at = CURRENT_TIMESTAMP WHERE event_id = ${this.p(2)}`,
|
|
@@ -354,6 +436,44 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
|
|
|
354
436
|
return rows[0] ?? null;
|
|
355
437
|
}
|
|
356
438
|
|
|
439
|
+
private async processWebhookEventOnceWithoutTransaction(
|
|
440
|
+
cleanEventId: string,
|
|
441
|
+
handler: () => Promise<void>,
|
|
442
|
+
): Promise<"processed" | "duplicate"> {
|
|
443
|
+
const parts = sqlParts(this.options);
|
|
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`,
|
|
446
|
+
[cleanEventId],
|
|
447
|
+
);
|
|
448
|
+
const existingStatus = existing.length ? String(existing[0]?.status || "") : "";
|
|
449
|
+
if (existingStatus === "processed") return "duplicate";
|
|
450
|
+
if (existingStatus === "processing" && !webhookProcessingIsStale(existing[0]?.created_at)) return "duplicate";
|
|
451
|
+
if (existingStatus === "failed" || existingStatus === "processing") {
|
|
452
|
+
await this.options.executor.execute(
|
|
453
|
+
`UPDATE ${parts.qEvents} SET status = ${this.p(1)}, error_message = NULL, processed_at = NULL WHERE event_id = ${this.p(2)}`,
|
|
454
|
+
["processing", cleanEventId],
|
|
455
|
+
);
|
|
456
|
+
} else {
|
|
457
|
+
const inserted = await this.executeChanged(insertWebhookEventSql(this.options, cleanEventId), [cleanEventId, "processing"]);
|
|
458
|
+
if (inserted === 0) return "duplicate";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
await handler();
|
|
463
|
+
await this.options.executor.execute(
|
|
464
|
+
`UPDATE ${parts.qEvents} SET status = ${this.p(1)}, error_message = NULL, processed_at = CURRENT_TIMESTAMP WHERE event_id = ${this.p(2)}`,
|
|
465
|
+
["processed", cleanEventId],
|
|
466
|
+
);
|
|
467
|
+
return "processed";
|
|
468
|
+
} catch (error) {
|
|
469
|
+
await this.options.executor.execute(
|
|
470
|
+
`UPDATE ${parts.qEvents} SET status = ${this.p(1)}, error_message = ${this.p(2)}, processed_at = NULL WHERE event_id = ${this.p(3)}`,
|
|
471
|
+
["failed", errorMessage(error), cleanEventId],
|
|
472
|
+
);
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
357
477
|
async markOrderPaidOnce(input: {
|
|
358
478
|
order_id: string;
|
|
359
479
|
requirement_id: string;
|
|
@@ -362,9 +482,9 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
|
|
|
362
482
|
const parts = sqlParts(this.options);
|
|
363
483
|
const changed = await this.executeChanged(
|
|
364
484
|
`UPDATE ${parts.qAttempts}
|
|
365
|
-
SET status = ${this.p(1)}, requirement_id = ${this.p(2)}, chain_receipt_id = ${this.p(3)}, paid_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
366
|
-
WHERE order_id = ${this.p(4)} AND status
|
|
367
|
-
["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"],
|
|
368
488
|
);
|
|
369
489
|
if (changed && parts.qOrderStatus) {
|
|
370
490
|
const updatedAt = parts.qOrderUpdatedAt ? `, ${parts.qOrderUpdatedAt} = CURRENT_TIMESTAMP` : "";
|
|
@@ -383,9 +503,9 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
|
|
|
383
503
|
const parts = sqlParts(this.options);
|
|
384
504
|
const changed = await this.executeChanged(
|
|
385
505
|
`UPDATE ${parts.qAttempts}
|
|
386
|
-
SET status = ${this.p(1)}, requirement_id = ${this.p(2)}, pricing_band = ${this.p(3)}, fulfilled_unsettled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
387
|
-
WHERE order_id = ${this.p(4)} AND status NOT IN (${this.p(5)}, ${this.p(6)})`,
|
|
388
|
-
["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"],
|
|
389
509
|
);
|
|
390
510
|
if (changed && parts.qOrderStatus) {
|
|
391
511
|
const updatedAt = parts.qOrderUpdatedAt ? `, ${parts.qOrderUpdatedAt} = CURRENT_TIMESTAMP` : "";
|
|
@@ -422,8 +542,61 @@ class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
|
|
|
422
542
|
return rows[0] ?? null;
|
|
423
543
|
}
|
|
424
544
|
|
|
425
|
-
private async
|
|
426
|
-
|
|
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);
|
|
427
600
|
}
|
|
428
601
|
|
|
429
602
|
private async executeChanged(statement: string, params: readonly unknown[]): Promise<number | null> {
|
|
@@ -483,9 +656,12 @@ function sqlParts(options: NormalizedOptions): SqlParts {
|
|
|
483
656
|
function insertAttemptSql(options: NormalizedOptions): string {
|
|
484
657
|
const parts = sqlParts(options);
|
|
485
658
|
if (options.dialect === "mysql") {
|
|
486
|
-
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)})`;
|
|
487
661
|
}
|
|
488
|
-
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`;
|
|
489
665
|
}
|
|
490
666
|
|
|
491
667
|
function insertWebhookEventSql(options: NormalizedOptions, _eventId: string): string {
|
|
@@ -510,8 +686,8 @@ function quoteIdentifier(identifier: string, dialect: SiglumeSqlDialect): string
|
|
|
510
686
|
}).join(".");
|
|
511
687
|
}
|
|
512
688
|
|
|
513
|
-
function stableAttempt(orderId: string): { attempt_id: string; stable_nonce: string } {
|
|
514
|
-
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);
|
|
515
691
|
return {
|
|
516
692
|
attempt_id: `sdrp_attempt_${digest}`,
|
|
517
693
|
stable_nonce: `sdrp-${digest}`,
|
|
@@ -536,6 +712,57 @@ function textOrNull(value: unknown): string | null {
|
|
|
536
712
|
return typeof value === "string" && value ? value : null;
|
|
537
713
|
}
|
|
538
714
|
|
|
715
|
+
function errorMessage(error: unknown): string {
|
|
716
|
+
if (error instanceof Error) return error.message.slice(0, 1000);
|
|
717
|
+
return String(error || "webhook handler failed").slice(0, 1000);
|
|
718
|
+
}
|
|
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
|
+
|
|
539
766
|
function normalizeRows(value: unknown): Record<string, unknown>[] {
|
|
540
767
|
if (Array.isArray(value)) return value as Record<string, unknown>[];
|
|
541
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:
|