@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.
- package/CHANGELOG.md +38 -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 +14 -2
- package/templates/express/siglume-order-store.sql.ts +272 -54
- 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,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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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)},
|
|
319
|
-
checkout_session_id = ${this.p(
|
|
320
|
-
|
|
321
|
-
|
|
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"
|
|
338
|
-
if (existingStatus === "
|
|
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"
|
|
376
|
-
if (existingStatus === "
|
|
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
|
|
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
|
|
471
|
-
|
|
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,
|
|
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,
|
|
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))
|
|
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
|
-
|
|
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:
|