@siglume/direct-request-payment 0.4.25 → 0.4.26

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.
@@ -0,0 +1,691 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ CreateTableCommand,
4
+ DescribeTableCommand,
5
+ type DynamoDBClient,
6
+ waitUntilTableExists,
7
+ } from "@aws-sdk/client-dynamodb";
8
+ import {
9
+ DeleteCommand,
10
+ DynamoDBDocumentClient,
11
+ GetCommand,
12
+ PutCommand,
13
+ QueryCommand,
14
+ TransactWriteCommand,
15
+ UpdateCommand,
16
+ } from "@aws-sdk/lib-dynamodb";
17
+ import type { Request } from "express";
18
+
19
+ import type { SiglumeCheckoutAttempt, SiglumeSdrpOrderStore } from "./siglume-sdrp-routes.js";
20
+
21
+ export interface DynamoDbSiglumeOrderStoreOptions {
22
+ client: DynamoDBDocumentClient;
23
+ orders_table?: string;
24
+ order_id_attribute?: string;
25
+ amount_minor_attribute?: string;
26
+ currency_attribute?: string;
27
+ order_status_attribute?: string | null;
28
+ order_updated_at_attribute?: string | null;
29
+ checkout_attempts_table?: string;
30
+ webhook_events_table?: string;
31
+ payment_reviews_table?: string;
32
+ challenge_hash_index?: string;
33
+ order_id_index?: string;
34
+ authorize_order?: (order: Record<string, unknown>, req: Request) => boolean | Promise<boolean>;
35
+ }
36
+
37
+ export interface DynamoDbSiglumeTableOptions extends Omit<DynamoDbSiglumeOrderStoreOptions, "client" | "authorize_order"> {
38
+ client: DynamoDBClient;
39
+ include_orders_table?: boolean;
40
+ }
41
+
42
+ interface NormalizedOptions {
43
+ client: DynamoDBDocumentClient;
44
+ orders_table: string;
45
+ order_id_attribute: string;
46
+ amount_minor_attribute: string;
47
+ currency_attribute: string;
48
+ order_status_attribute: string | null;
49
+ order_updated_at_attribute: string | null;
50
+ checkout_attempts_table: string;
51
+ webhook_events_table: string;
52
+ payment_reviews_table: string;
53
+ challenge_hash_index: string;
54
+ order_id_index: string;
55
+ authorize_order?: (order: Record<string, unknown>, req: Request) => boolean | Promise<boolean>;
56
+ }
57
+
58
+ const CHECKOUT_CREATION_LEASE_MS = 30_000;
59
+ const CHECKOUT_CREATION_WAIT_MS = 10_000;
60
+ const CHECKOUT_CREATION_POLL_MS = 100;
61
+ const WEBHOOK_PROCESSING_STALE_MS = 10 * 60 * 1000;
62
+
63
+ export function createDynamoDbSiglumeOrderStore(options: DynamoDbSiglumeOrderStoreOptions): SiglumeSdrpOrderStore {
64
+ return new DynamoDbSiglumeOrderStore(normalizeOptions(options));
65
+ }
66
+
67
+ export async function createDynamoDbSiglumeTables(options: DynamoDbSiglumeTableOptions): Promise<void> {
68
+ const normalized = normalizeTableOptions(options);
69
+ if (options.include_orders_table !== false) {
70
+ await createTableIfMissing(normalized.client, normalized.orders_table, normalized.order_id_attribute);
71
+ }
72
+ await createAttemptsTableIfMissing(
73
+ normalized.client,
74
+ normalized.checkout_attempts_table,
75
+ normalized.challenge_hash_index,
76
+ normalized.order_id_index,
77
+ );
78
+ await createTableIfMissing(normalized.client, normalized.webhook_events_table, "event_id");
79
+ await createTableIfMissing(normalized.client, normalized.payment_reviews_table, "review_id");
80
+ }
81
+
82
+ class DynamoDbSiglumeOrderStore implements SiglumeSdrpOrderStore {
83
+ constructor(private readonly options: NormalizedOptions) {}
84
+
85
+ async beginCheckoutAttempt(orderId: string, req: Request): Promise<SiglumeCheckoutAttempt | null> {
86
+ const cleanOrderId = requireText(orderId, "order_id");
87
+ const waitUntil = Date.now() + CHECKOUT_CREATION_WAIT_MS;
88
+ for (;;) {
89
+ const order = await this.findProductOrder(cleanOrderId);
90
+ if (!order) return null;
91
+ if (this.options.authorize_order && !(await this.options.authorize_order(order, req))) return null;
92
+
93
+ const active = await this.getActiveAttempt(cleanOrderId);
94
+ if (active && isReusableCheckoutAttempt(active)) return this.toCheckoutAttempt(order, active);
95
+ if (active && isCreatingCheckoutAttempt(active) && !timestampHasPassed(active.creation_lease_expires_at)) {
96
+ if (Date.now() >= waitUntil) return this.toCheckoutAttempt(order, active, true);
97
+ await sleep(CHECKOUT_CREATION_POLL_MS);
98
+ continue;
99
+ }
100
+ if (active) await this.releaseInactiveAttempt(active, active.status === "pending" ? "expired" : "failed");
101
+
102
+ const attemptNumber = active ? Number(active.attempt_number || 0) + 1 : await this.nextAttemptNumber(cleanOrderId);
103
+ const attempt = stableAttempt(cleanOrderId, attemptNumber);
104
+ const item = {
105
+ pk: attemptPk(attempt.attempt_id),
106
+ item_type: "attempt",
107
+ order_id: cleanOrderId,
108
+ attempt_number: attemptNumber,
109
+ attempt_id: attempt.attempt_id,
110
+ stable_nonce: attempt.stable_nonce,
111
+ status: "creating",
112
+ creation_owner_id: `sdrp_create_${randomUUID()}`,
113
+ creation_lease_expires_at: timestamp(Date.now() + CHECKOUT_CREATION_LEASE_MS),
114
+ created_at: timestamp(),
115
+ updated_at: timestamp(),
116
+ };
117
+ const activeItem = { ...item, pk: activePk(cleanOrderId), item_type: "active" };
118
+ try {
119
+ await this.options.client.send(new TransactWriteCommand({
120
+ TransactItems: [
121
+ {
122
+ Put: {
123
+ TableName: this.options.checkout_attempts_table,
124
+ Item: activeItem,
125
+ ConditionExpression: "attribute_not_exists(pk)",
126
+ },
127
+ },
128
+ {
129
+ Put: {
130
+ TableName: this.options.checkout_attempts_table,
131
+ Item: item,
132
+ ConditionExpression: "attribute_not_exists(pk)",
133
+ },
134
+ },
135
+ ],
136
+ }));
137
+ return {
138
+ id: String(order.id),
139
+ order_id: String(order.id),
140
+ amount_minor: Number(order.amount_minor),
141
+ currency: String(order.currency),
142
+ attempt_number: attemptNumber,
143
+ attempt_id: attempt.attempt_id,
144
+ stable_nonce: attempt.stable_nonce,
145
+ status: "creating",
146
+ };
147
+ } catch (error) {
148
+ if (isConditionalFailure(error)) {
149
+ if (Date.now() >= waitUntil) return this.toCheckoutAttempt(order, activeItem, true);
150
+ await sleep(CHECKOUT_CREATION_POLL_MS);
151
+ continue;
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+ }
157
+
158
+ async markCheckoutPending(input: {
159
+ order_id: string;
160
+ attempt_id: string;
161
+ stable_nonce: string;
162
+ challenge_hash: string;
163
+ checkout_session_id: string;
164
+ checkout_url: string;
165
+ expires_at?: string | null;
166
+ }): Promise<void> {
167
+ const values = {
168
+ ":pending": "pending",
169
+ ":creating": "creating",
170
+ ":stable_nonce": input.stable_nonce,
171
+ ":challenge_hash": input.challenge_hash,
172
+ ":checkout_session_id": input.checkout_session_id,
173
+ ":checkout_url": input.checkout_url,
174
+ ":expires_at": timestampOrNull(input.expires_at),
175
+ ":updated_at": timestamp(),
176
+ };
177
+ const activeValues = {
178
+ ...values,
179
+ ":attempt_id": input.attempt_id,
180
+ };
181
+ await this.options.client.send(new TransactWriteCommand({
182
+ TransactItems: [
183
+ {
184
+ Update: {
185
+ TableName: this.options.checkout_attempts_table,
186
+ Key: { pk: activePk(input.order_id) },
187
+ UpdateExpression: "SET #status = :pending, stable_nonce = :stable_nonce, challenge_hash = :challenge_hash, checkout_session_id = :checkout_session_id, checkout_url = :checkout_url, expires_at = :expires_at, updated_at = :updated_at REMOVE creation_owner_id, creation_lease_expires_at, error_message",
188
+ ConditionExpression: "attempt_id = :attempt_id AND #status = :creating",
189
+ ExpressionAttributeNames: { "#status": "status" },
190
+ ExpressionAttributeValues: activeValues,
191
+ },
192
+ },
193
+ {
194
+ Update: {
195
+ TableName: this.options.checkout_attempts_table,
196
+ Key: { pk: attemptPk(input.attempt_id) },
197
+ UpdateExpression: "SET #status = :pending, stable_nonce = :stable_nonce, challenge_hash = :challenge_hash, checkout_session_id = :checkout_session_id, checkout_url = :checkout_url, expires_at = :expires_at, updated_at = :updated_at REMOVE creation_owner_id, creation_lease_expires_at, error_message",
198
+ ConditionExpression: "attempt_id = :attempt_id AND #status = :creating",
199
+ ExpressionAttributeNames: { "#status": "status" },
200
+ ExpressionAttributeValues: activeValues,
201
+ },
202
+ },
203
+ ],
204
+ }));
205
+ }
206
+
207
+ async markCheckoutFailed(input: {
208
+ order_id: string;
209
+ attempt_id: string;
210
+ error_message?: string;
211
+ }): Promise<void> {
212
+ await this.options.client.send(new TransactWriteCommand({
213
+ TransactItems: [
214
+ {
215
+ Delete: {
216
+ TableName: this.options.checkout_attempts_table,
217
+ Key: { pk: activePk(input.order_id) },
218
+ ConditionExpression: "attempt_id = :attempt_id",
219
+ ExpressionAttributeValues: { ":attempt_id": input.attempt_id },
220
+ },
221
+ },
222
+ {
223
+ Update: {
224
+ TableName: this.options.checkout_attempts_table,
225
+ Key: { pk: attemptPk(input.attempt_id) },
226
+ UpdateExpression: "SET #status = :failed, failed_at = :failed_at, error_message = :error_message, updated_at = :updated_at REMOVE creation_owner_id, creation_lease_expires_at",
227
+ ConditionExpression: "#status = :creating",
228
+ ExpressionAttributeNames: { "#status": "status" },
229
+ ExpressionAttributeValues: {
230
+ ":failed": "failed",
231
+ ":failed_at": timestamp(),
232
+ ":error_message": textOrNull(input.error_message),
233
+ ":updated_at": timestamp(),
234
+ },
235
+ },
236
+ },
237
+ ],
238
+ }));
239
+ }
240
+
241
+ async processWebhookEventOnce(eventId: string, handler: () => Promise<void>): Promise<"processed" | "duplicate"> {
242
+ const cleanEventId = requireText(eventId, "event_id");
243
+ const existing = await this.options.client.send(new GetCommand({
244
+ TableName: this.options.webhook_events_table,
245
+ Key: { event_id: cleanEventId },
246
+ }));
247
+ if (existing.Item?.status === "processed") return "duplicate";
248
+ if (existing.Item?.status === "processing" && !webhookProcessingIsStale(existing.Item.created_at)) return "duplicate";
249
+
250
+ if (existing.Item) {
251
+ await this.options.client.send(new UpdateCommand({
252
+ TableName: this.options.webhook_events_table,
253
+ Key: { event_id: cleanEventId },
254
+ UpdateExpression: "SET #status = :processing, error_message = :null_value, processed_at = :null_value, created_at = :created_at",
255
+ ExpressionAttributeNames: { "#status": "status" },
256
+ ExpressionAttributeValues: { ":processing": "processing", ":null_value": null, ":created_at": timestamp() },
257
+ }));
258
+ } else {
259
+ try {
260
+ await this.options.client.send(new PutCommand({
261
+ TableName: this.options.webhook_events_table,
262
+ Item: { event_id: cleanEventId, status: "processing", created_at: timestamp(), processed_at: null },
263
+ ConditionExpression: "attribute_not_exists(event_id)",
264
+ }));
265
+ } catch (error) {
266
+ if (isConditionalFailure(error)) return "duplicate";
267
+ throw error;
268
+ }
269
+ }
270
+
271
+ try {
272
+ await handler();
273
+ await this.options.client.send(new UpdateCommand({
274
+ TableName: this.options.webhook_events_table,
275
+ Key: { event_id: cleanEventId },
276
+ UpdateExpression: "SET #status = :processed, error_message = :null_value, processed_at = :processed_at",
277
+ ExpressionAttributeNames: { "#status": "status" },
278
+ ExpressionAttributeValues: { ":processed": "processed", ":null_value": null, ":processed_at": timestamp() },
279
+ }));
280
+ return "processed";
281
+ } catch (error) {
282
+ await this.options.client.send(new UpdateCommand({
283
+ TableName: this.options.webhook_events_table,
284
+ Key: { event_id: cleanEventId },
285
+ UpdateExpression: "SET #status = :failed, error_message = :error_message, processed_at = :null_value",
286
+ ExpressionAttributeNames: { "#status": "status" },
287
+ ExpressionAttributeValues: { ":failed": "failed", ":error_message": errorMessage(error), ":null_value": null },
288
+ }));
289
+ throw error;
290
+ }
291
+ }
292
+
293
+ async findOrderByChallengeHash(challengeHash: string): Promise<{ id: string } | null> {
294
+ const result = await this.options.client.send(new QueryCommand({
295
+ TableName: this.options.checkout_attempts_table,
296
+ IndexName: this.options.challenge_hash_index,
297
+ KeyConditionExpression: "challenge_hash = :challenge_hash",
298
+ ExpressionAttributeValues: { ":challenge_hash": challengeHash },
299
+ Limit: 1,
300
+ }));
301
+ const item = result.Items?.[0];
302
+ return item?.order_id ? { id: String(item.order_id) } : null;
303
+ }
304
+
305
+ async markOrderPaidOnce(input: {
306
+ order_id: string;
307
+ requirement_id: string;
308
+ chain_receipt_id: string;
309
+ }): Promise<void> {
310
+ const active = await this.getActiveAttempt(input.order_id);
311
+ if (!active) return;
312
+ try {
313
+ await this.options.client.send(new TransactWriteCommand({
314
+ TransactItems: [
315
+ {
316
+ Delete: {
317
+ TableName: this.options.checkout_attempts_table,
318
+ Key: { pk: activePk(input.order_id) },
319
+ ConditionExpression: "attempt_id = :attempt_id",
320
+ ExpressionAttributeValues: { ":attempt_id": active.attempt_id },
321
+ },
322
+ },
323
+ {
324
+ Update: {
325
+ TableName: this.options.checkout_attempts_table,
326
+ Key: { pk: attemptPk(String(active.attempt_id)) },
327
+ UpdateExpression: "SET #status = :paid, requirement_id = :requirement_id, chain_receipt_id = :chain_receipt_id, paid_at = :paid_at, updated_at = :updated_at",
328
+ ConditionExpression: "NOT (#status IN (:paid, :expired, :cancelled, :failed))",
329
+ ExpressionAttributeNames: { "#status": "status" },
330
+ ExpressionAttributeValues: {
331
+ ":paid": "paid",
332
+ ":expired": "expired",
333
+ ":cancelled": "cancelled",
334
+ ":failed": "failed",
335
+ ":requirement_id": input.requirement_id,
336
+ ":chain_receipt_id": input.chain_receipt_id,
337
+ ":paid_at": timestamp(),
338
+ ":updated_at": timestamp(),
339
+ },
340
+ },
341
+ },
342
+ this.orderStatusUpdate(input.order_id, "paid"),
343
+ ],
344
+ }));
345
+ } catch (error) {
346
+ if (!isConditionalFailure(error)) throw error;
347
+ }
348
+ }
349
+
350
+ async markOrderFulfilledUnsettledOnce(input: {
351
+ order_id: string;
352
+ requirement_id: string;
353
+ pricing_band: string;
354
+ }): Promise<void> {
355
+ const active = await this.getActiveAttempt(input.order_id);
356
+ if (!active) return;
357
+ try {
358
+ await this.options.client.send(new TransactWriteCommand({
359
+ TransactItems: [
360
+ {
361
+ Delete: {
362
+ TableName: this.options.checkout_attempts_table,
363
+ Key: { pk: activePk(input.order_id) },
364
+ ConditionExpression: "attempt_id = :attempt_id",
365
+ ExpressionAttributeValues: { ":attempt_id": active.attempt_id },
366
+ },
367
+ },
368
+ {
369
+ Update: {
370
+ TableName: this.options.checkout_attempts_table,
371
+ Key: { pk: attemptPk(String(active.attempt_id)) },
372
+ UpdateExpression: "SET #status = :fulfilled, requirement_id = :requirement_id, pricing_band = :pricing_band, fulfilled_unsettled_at = :fulfilled_at, updated_at = :updated_at",
373
+ ConditionExpression: "NOT (#status IN (:fulfilled, :paid, :expired, :cancelled, :failed))",
374
+ ExpressionAttributeNames: { "#status": "status" },
375
+ ExpressionAttributeValues: {
376
+ ":fulfilled": "fulfilled_unsettled",
377
+ ":paid": "paid",
378
+ ":expired": "expired",
379
+ ":cancelled": "cancelled",
380
+ ":failed": "failed",
381
+ ":requirement_id": input.requirement_id,
382
+ ":pricing_band": input.pricing_band,
383
+ ":fulfilled_at": timestamp(),
384
+ ":updated_at": timestamp(),
385
+ },
386
+ },
387
+ },
388
+ this.orderStatusUpdate(input.order_id, "fulfilled_unsettled"),
389
+ ],
390
+ }));
391
+ } catch (error) {
392
+ if (!isConditionalFailure(error)) throw error;
393
+ }
394
+ }
395
+
396
+ async flagPaymentReview(input: Record<string, unknown>): Promise<void> {
397
+ await this.options.client.send(new PutCommand({
398
+ TableName: this.options.payment_reviews_table,
399
+ Item: {
400
+ review_id: `sdrp_review_${hash(`${Date.now()}:${JSON.stringify(input)}`).slice(0, 24)}`,
401
+ order_id: textOrNull(input.order_id),
402
+ reason: String(input.reason || "manual_review_required"),
403
+ payload_json: input,
404
+ created_at: timestamp(),
405
+ },
406
+ }));
407
+ }
408
+
409
+ private async findProductOrder(orderId: string): Promise<Record<string, unknown> | null> {
410
+ const result = await this.options.client.send(new GetCommand({
411
+ TableName: this.options.orders_table,
412
+ Key: { [this.options.order_id_attribute]: orderId },
413
+ }));
414
+ const row = result.Item;
415
+ if (!row) return null;
416
+ return {
417
+ ...row,
418
+ id: row[this.options.order_id_attribute] ?? orderId,
419
+ amount_minor: row[this.options.amount_minor_attribute],
420
+ currency: row[this.options.currency_attribute],
421
+ };
422
+ }
423
+
424
+ private async getActiveAttempt(orderId: string): Promise<Record<string, unknown> | null> {
425
+ const result = await this.options.client.send(new GetCommand({
426
+ TableName: this.options.checkout_attempts_table,
427
+ Key: { pk: activePk(orderId) },
428
+ ConsistentRead: true,
429
+ }));
430
+ return result.Item ?? null;
431
+ }
432
+
433
+ private async nextAttemptNumber(orderId: string): Promise<number> {
434
+ const result = await this.options.client.send(new QueryCommand({
435
+ TableName: this.options.checkout_attempts_table,
436
+ IndexName: this.options.order_id_index,
437
+ KeyConditionExpression: "order_id = :order_id",
438
+ ExpressionAttributeValues: { ":order_id": orderId },
439
+ ScanIndexForward: false,
440
+ Limit: 1,
441
+ }));
442
+ const current = Number(result.Items?.[0]?.attempt_number || 0);
443
+ return Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
444
+ }
445
+
446
+ private async releaseInactiveAttempt(active: Record<string, unknown>, status: "expired" | "failed"): Promise<void> {
447
+ const timestampField = status === "expired" ? "expires_at" : "failed_at";
448
+ await this.options.client.send(new TransactWriteCommand({
449
+ TransactItems: [
450
+ {
451
+ Delete: {
452
+ TableName: this.options.checkout_attempts_table,
453
+ Key: { pk: activePk(String(active.order_id)) },
454
+ ConditionExpression: "attempt_id = :attempt_id",
455
+ ExpressionAttributeValues: { ":attempt_id": active.attempt_id },
456
+ },
457
+ },
458
+ {
459
+ Update: {
460
+ TableName: this.options.checkout_attempts_table,
461
+ Key: { pk: attemptPk(String(active.attempt_id)) },
462
+ UpdateExpression: `SET #status = :status, ${timestampField} = if_not_exists(${timestampField}, :now_value), updated_at = :now_value REMOVE creation_owner_id, creation_lease_expires_at`,
463
+ ExpressionAttributeNames: { "#status": "status" },
464
+ ExpressionAttributeValues: { ":status": status, ":now_value": timestamp() },
465
+ },
466
+ },
467
+ ],
468
+ }));
469
+ }
470
+
471
+ private orderStatusUpdate(orderId: string, status: string) {
472
+ const expressionNames: Record<string, string> = {};
473
+ const expressionValues: Record<string, unknown> = {};
474
+ const setParts: string[] = [];
475
+ if (this.options.order_status_attribute) {
476
+ expressionNames["#order_status"] = this.options.order_status_attribute;
477
+ expressionValues[":order_status"] = status;
478
+ setParts.push("#order_status = :order_status");
479
+ }
480
+ if (this.options.order_updated_at_attribute) {
481
+ expressionNames["#order_updated_at"] = this.options.order_updated_at_attribute;
482
+ expressionValues[":order_updated_at"] = timestamp();
483
+ setParts.push("#order_updated_at = :order_updated_at");
484
+ }
485
+ return {
486
+ Update: {
487
+ TableName: this.options.orders_table,
488
+ Key: { [this.options.order_id_attribute]: orderId },
489
+ UpdateExpression: `SET ${setParts.join(", ") || "#sdrp_touched = :sdrp_touched"}`,
490
+ ExpressionAttributeNames: setParts.length ? expressionNames : { "#sdrp_touched": "sdrp_touched_at" },
491
+ ExpressionAttributeValues: setParts.length ? expressionValues : { ":sdrp_touched": timestamp() },
492
+ },
493
+ };
494
+ }
495
+
496
+ private toCheckoutAttempt(order: Record<string, unknown>, state: Record<string, unknown>, pending = false): SiglumeCheckoutAttempt {
497
+ return {
498
+ id: String(order.id),
499
+ order_id: String(order.id),
500
+ amount_minor: Number(order.amount_minor),
501
+ currency: String(order.currency),
502
+ attempt_number: Number(state.attempt_number || 1),
503
+ attempt_id: String(state.attempt_id),
504
+ stable_nonce: String(state.stable_nonce),
505
+ status: String(state.status || ""),
506
+ checkout_session_id: textOrUndefined(state.checkout_session_id),
507
+ checkout_url: textOrUndefined(state.checkout_url),
508
+ expires_at: timestampTextOrNull(state.expires_at),
509
+ checkout_creation_pending: pending,
510
+ };
511
+ }
512
+ }
513
+
514
+ function normalizeOptions(options: DynamoDbSiglumeOrderStoreOptions): NormalizedOptions {
515
+ return {
516
+ client: options.client,
517
+ orders_table: options.orders_table ?? "orders",
518
+ order_id_attribute: options.order_id_attribute ?? "id",
519
+ amount_minor_attribute: options.amount_minor_attribute ?? "amount_minor",
520
+ currency_attribute: options.currency_attribute ?? "currency",
521
+ order_status_attribute: options.order_status_attribute === undefined ? "status" : options.order_status_attribute,
522
+ order_updated_at_attribute: options.order_updated_at_attribute === undefined ? "updated_at" : options.order_updated_at_attribute,
523
+ checkout_attempts_table: options.checkout_attempts_table ?? "siglume_checkout_attempts",
524
+ webhook_events_table: options.webhook_events_table ?? "siglume_webhook_events",
525
+ payment_reviews_table: options.payment_reviews_table ?? "siglume_payment_reviews",
526
+ challenge_hash_index: options.challenge_hash_index ?? "challenge_hash_index",
527
+ order_id_index: options.order_id_index ?? "order_id_index",
528
+ authorize_order: options.authorize_order,
529
+ };
530
+ }
531
+
532
+ function normalizeTableOptions(options: DynamoDbSiglumeTableOptions): Omit<NormalizedOptions, "client" | "authorize_order"> & { client: DynamoDBClient } {
533
+ return {
534
+ client: options.client,
535
+ orders_table: options.orders_table ?? "orders",
536
+ order_id_attribute: options.order_id_attribute ?? "id",
537
+ amount_minor_attribute: options.amount_minor_attribute ?? "amount_minor",
538
+ currency_attribute: options.currency_attribute ?? "currency",
539
+ order_status_attribute: options.order_status_attribute === undefined ? "status" : options.order_status_attribute,
540
+ order_updated_at_attribute: options.order_updated_at_attribute === undefined ? "updated_at" : options.order_updated_at_attribute,
541
+ checkout_attempts_table: options.checkout_attempts_table ?? "siglume_checkout_attempts",
542
+ webhook_events_table: options.webhook_events_table ?? "siglume_webhook_events",
543
+ payment_reviews_table: options.payment_reviews_table ?? "siglume_payment_reviews",
544
+ challenge_hash_index: options.challenge_hash_index ?? "challenge_hash_index",
545
+ order_id_index: options.order_id_index ?? "order_id_index",
546
+ };
547
+ }
548
+
549
+ async function createTableIfMissing(client: DynamoDBClient, tableName: string, keyName: string): Promise<void> {
550
+ if (await tableExists(client, tableName)) return;
551
+ await client.send(new CreateTableCommand({
552
+ TableName: tableName,
553
+ BillingMode: "PAY_PER_REQUEST",
554
+ AttributeDefinitions: [{ AttributeName: keyName, AttributeType: "S" }],
555
+ KeySchema: [{ AttributeName: keyName, KeyType: "HASH" }],
556
+ }));
557
+ await waitUntilTableExists({ client: client as DynamoDBClient, maxWaitTime: 30 }, { TableName: tableName });
558
+ }
559
+
560
+ async function createAttemptsTableIfMissing(
561
+ client: DynamoDBClient,
562
+ tableName: string,
563
+ challengeHashIndex: string,
564
+ orderIdIndex: string,
565
+ ): Promise<void> {
566
+ if (await tableExists(client, tableName)) return;
567
+ await client.send(new CreateTableCommand({
568
+ TableName: tableName,
569
+ BillingMode: "PAY_PER_REQUEST",
570
+ AttributeDefinitions: [
571
+ { AttributeName: "pk", AttributeType: "S" },
572
+ { AttributeName: "challenge_hash", AttributeType: "S" },
573
+ { AttributeName: "order_id", AttributeType: "S" },
574
+ { AttributeName: "attempt_number", AttributeType: "N" },
575
+ ],
576
+ KeySchema: [{ AttributeName: "pk", KeyType: "HASH" }],
577
+ GlobalSecondaryIndexes: [
578
+ {
579
+ IndexName: challengeHashIndex,
580
+ KeySchema: [{ AttributeName: "challenge_hash", KeyType: "HASH" }],
581
+ Projection: { ProjectionType: "ALL" },
582
+ },
583
+ {
584
+ IndexName: orderIdIndex,
585
+ KeySchema: [
586
+ { AttributeName: "order_id", KeyType: "HASH" },
587
+ { AttributeName: "attempt_number", KeyType: "RANGE" },
588
+ ],
589
+ Projection: { ProjectionType: "ALL" },
590
+ },
591
+ ],
592
+ }));
593
+ await waitUntilTableExists({ client: client as DynamoDBClient, maxWaitTime: 30 }, { TableName: tableName });
594
+ }
595
+
596
+ async function tableExists(client: DynamoDBClient, tableName: string): Promise<boolean> {
597
+ try {
598
+ await client.send(new DescribeTableCommand({ TableName: tableName }));
599
+ return true;
600
+ } catch (error) {
601
+ if (String((error as { name?: string }).name || "") === "ResourceNotFoundException") return false;
602
+ throw error;
603
+ }
604
+ }
605
+
606
+ function activePk(orderId: string): string {
607
+ return `active#${hash(orderId).slice(0, 32)}`;
608
+ }
609
+
610
+ function attemptPk(attemptId: string): string {
611
+ return `attempt#${attemptId}`;
612
+ }
613
+
614
+ function stableAttempt(orderId: string, attemptNumber: number): { attempt_id: string; stable_nonce: string } {
615
+ const digest = hash(`${orderId}:${attemptNumber}`).slice(0, 32);
616
+ return {
617
+ attempt_id: `sdrp_attempt_${digest}`,
618
+ stable_nonce: `sdrp-${digest}`,
619
+ };
620
+ }
621
+
622
+ function hash(value: string): string {
623
+ return createHash("sha256").update(value).digest("hex");
624
+ }
625
+
626
+ function requireText(value: string, name: string): string {
627
+ const text = String(value || "").trim();
628
+ if (!text) throw new Error(`${name} is required.`);
629
+ return text;
630
+ }
631
+
632
+ function timestamp(value: number | string | Date = Date.now()): string {
633
+ const date = value instanceof Date ? value : new Date(value);
634
+ return date.toISOString();
635
+ }
636
+
637
+ function timestampOrNull(value: unknown): string | null {
638
+ if (!value) return null;
639
+ const parsed = Date.parse(String(value));
640
+ return Number.isFinite(parsed) ? timestamp(parsed) : null;
641
+ }
642
+
643
+ function timestampTextOrNull(value: unknown): string | null {
644
+ if (!value) return null;
645
+ return String(value);
646
+ }
647
+
648
+ function timestampHasPassed(value: unknown): boolean {
649
+ if (!value) return false;
650
+ const parsed = Date.parse(String(value));
651
+ return Number.isFinite(parsed) && parsed <= Date.now();
652
+ }
653
+
654
+ function webhookProcessingIsStale(value: unknown): boolean {
655
+ if (!value) return false;
656
+ const parsed = Date.parse(String(value));
657
+ return Number.isFinite(parsed) && Date.now() - parsed > WEBHOOK_PROCESSING_STALE_MS;
658
+ }
659
+
660
+ function isReusableCheckoutAttempt(state: Record<string, unknown>): boolean {
661
+ return String(state.status || "") === "pending"
662
+ && Boolean(textOrUndefined(state.checkout_session_id))
663
+ && Boolean(textOrUndefined(state.checkout_url))
664
+ && !timestampHasPassed(state.expires_at);
665
+ }
666
+
667
+ function isCreatingCheckoutAttempt(state: Record<string, unknown>): boolean {
668
+ return String(state.status || "") === "creating";
669
+ }
670
+
671
+ function textOrUndefined(value: unknown): string | undefined {
672
+ return typeof value === "string" && value ? value : undefined;
673
+ }
674
+
675
+ function textOrNull(value: unknown): string | null {
676
+ return typeof value === "string" && value ? value : null;
677
+ }
678
+
679
+ function errorMessage(error: unknown): string {
680
+ if (error instanceof Error) return error.message.slice(0, 1000);
681
+ return String(error || "webhook handler failed").slice(0, 1000);
682
+ }
683
+
684
+ function isConditionalFailure(error: unknown): boolean {
685
+ const name = String((error as { name?: string }).name || "");
686
+ return name === "ConditionalCheckFailedException" || name === "TransactionCanceledException";
687
+ }
688
+
689
+ function sleep(ms: number): Promise<void> {
690
+ return new Promise((resolve) => setTimeout(resolve, ms));
691
+ }