@siglume/direct-request-payment 0.4.24 → 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,418 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type { DocumentData, Firestore, Transaction } from "@google-cloud/firestore";
3
+ import type { Request } from "express";
4
+
5
+ import type { SiglumeCheckoutAttempt, SiglumeSdrpOrderStore } from "./siglume-sdrp-routes.js";
6
+
7
+ export interface FirestoreSiglumeOrderStoreOptions {
8
+ db: Firestore;
9
+ orders_collection?: string;
10
+ order_id_field?: string;
11
+ amount_minor_field?: string;
12
+ currency_field?: string;
13
+ order_status_field?: string | null;
14
+ order_updated_at_field?: string | null;
15
+ checkout_attempts_collection?: string;
16
+ webhook_events_collection?: string;
17
+ payment_reviews_collection?: string;
18
+ authorize_order?: (order: Record<string, unknown>, req: Request) => boolean | Promise<boolean>;
19
+ }
20
+
21
+ interface NormalizedOptions {
22
+ db: Firestore;
23
+ orders_collection: string;
24
+ order_id_field: string;
25
+ amount_minor_field: string;
26
+ currency_field: string;
27
+ order_status_field: string | null;
28
+ order_updated_at_field: string | null;
29
+ checkout_attempts_collection: string;
30
+ webhook_events_collection: string;
31
+ payment_reviews_collection: string;
32
+ authorize_order?: (order: Record<string, unknown>, req: Request) => boolean | Promise<boolean>;
33
+ }
34
+
35
+ const CHECKOUT_CREATION_LEASE_MS = 30_000;
36
+ const CHECKOUT_CREATION_WAIT_MS = 10_000;
37
+ const CHECKOUT_CREATION_POLL_MS = 100;
38
+ const WEBHOOK_PROCESSING_STALE_MS = 10 * 60 * 1000;
39
+
40
+ export function createFirestoreSiglumeOrderStore(options: FirestoreSiglumeOrderStoreOptions): SiglumeSdrpOrderStore {
41
+ return new FirestoreSiglumeOrderStore(normalizeOptions(options));
42
+ }
43
+
44
+ export async function createFirestoreSiglumeCollections(_options: FirestoreSiglumeOrderStoreOptions): Promise<void> {
45
+ return;
46
+ }
47
+
48
+ class FirestoreSiglumeOrderStore implements SiglumeSdrpOrderStore {
49
+ constructor(private readonly options: NormalizedOptions) {}
50
+
51
+ async beginCheckoutAttempt(orderId: string, req: Request): Promise<SiglumeCheckoutAttempt | null> {
52
+ const cleanOrderId = requireText(orderId, "order_id");
53
+ const waitUntil = Date.now() + CHECKOUT_CREATION_WAIT_MS;
54
+ for (;;) {
55
+ const order = await this.findProductOrder(cleanOrderId);
56
+ if (!order) return null;
57
+ if (this.options.authorize_order && !(await this.options.authorize_order(order, req))) return null;
58
+
59
+ const result = await this.options.db.runTransaction(async (tx) => {
60
+ const activeRef = this.activeRef(cleanOrderId);
61
+ const activeSnap = await tx.get(activeRef);
62
+ const active = activeSnap.exists ? activeSnap.data() ?? null : null;
63
+ if (active && isReusableCheckoutAttempt(active)) {
64
+ return { done: true, attempt: this.toCheckoutAttempt(order, active) };
65
+ }
66
+ if (active && isCreatingCheckoutAttempt(active) && !timestampHasPassed(active.creation_lease_expires_at)) {
67
+ return { done: false, attempt: this.toCheckoutAttempt(order, active, true) };
68
+ }
69
+ if (active) this.releaseAttemptHistoryTx(tx, active, active.status === "pending" ? "expired" : "failed");
70
+
71
+ const attemptNumber = active ? Number(active.attempt_number || 0) + 1 : await this.nextAttemptNumber(cleanOrderId);
72
+ const attempt = stableAttempt(cleanOrderId, attemptNumber);
73
+ const item = {
74
+ item_type: "attempt",
75
+ order_id: cleanOrderId,
76
+ attempt_number: attemptNumber,
77
+ attempt_id: attempt.attempt_id,
78
+ stable_nonce: attempt.stable_nonce,
79
+ status: "creating",
80
+ creation_owner_id: `sdrp_create_${randomUUID()}`,
81
+ creation_lease_expires_at: timestamp(Date.now() + CHECKOUT_CREATION_LEASE_MS),
82
+ created_at: timestamp(),
83
+ updated_at: timestamp(),
84
+ };
85
+ if (active) tx.set(activeRef, { ...item, item_type: "active", active_key: cleanOrderId });
86
+ else tx.create(activeRef, { ...item, item_type: "active", active_key: cleanOrderId });
87
+ tx.create(this.attemptRef(attempt.attempt_id), item);
88
+ return {
89
+ done: true,
90
+ attempt: {
91
+ id: String(order.id),
92
+ order_id: String(order.id),
93
+ amount_minor: Number(order.amount_minor),
94
+ currency: String(order.currency),
95
+ attempt_number: attemptNumber,
96
+ attempt_id: attempt.attempt_id,
97
+ stable_nonce: attempt.stable_nonce,
98
+ status: "creating",
99
+ } satisfies SiglumeCheckoutAttempt,
100
+ };
101
+ });
102
+
103
+ if (result.done) return result.attempt;
104
+ if (Date.now() >= waitUntil) return result.attempt;
105
+ await sleep(CHECKOUT_CREATION_POLL_MS);
106
+ }
107
+ }
108
+
109
+ async markCheckoutPending(input: {
110
+ order_id: string;
111
+ attempt_id: string;
112
+ stable_nonce: string;
113
+ challenge_hash: string;
114
+ checkout_session_id: string;
115
+ checkout_url: string;
116
+ expires_at?: string | null;
117
+ }): Promise<void> {
118
+ await this.options.db.runTransaction(async (tx) => {
119
+ const patch = {
120
+ status: "pending",
121
+ stable_nonce: input.stable_nonce,
122
+ challenge_hash: input.challenge_hash,
123
+ checkout_session_id: input.checkout_session_id,
124
+ checkout_url: input.checkout_url,
125
+ expires_at: timestampOrNull(input.expires_at),
126
+ creation_owner_id: null,
127
+ creation_lease_expires_at: null,
128
+ error_message: null,
129
+ updated_at: timestamp(),
130
+ };
131
+ tx.update(this.activeRef(input.order_id), patch);
132
+ tx.update(this.attemptRef(input.attempt_id), patch);
133
+ });
134
+ }
135
+
136
+ async markCheckoutFailed(input: {
137
+ order_id: string;
138
+ attempt_id: string;
139
+ error_message?: string;
140
+ }): Promise<void> {
141
+ await this.options.db.runTransaction(async (tx) => {
142
+ tx.delete(this.activeRef(input.order_id));
143
+ tx.update(this.attemptRef(input.attempt_id), {
144
+ status: "failed",
145
+ failed_at: timestamp(),
146
+ error_message: textOrNull(input.error_message),
147
+ creation_owner_id: null,
148
+ creation_lease_expires_at: null,
149
+ updated_at: timestamp(),
150
+ });
151
+ });
152
+ }
153
+
154
+ async processWebhookEventOnce(eventId: string, handler: () => Promise<void>): Promise<"processed" | "duplicate"> {
155
+ const cleanEventId = requireText(eventId, "event_id");
156
+ const eventRef = this.eventRef(cleanEventId);
157
+ const claimed = await this.options.db.runTransaction(async (tx) => {
158
+ const snap = await tx.get(eventRef);
159
+ const existing = snap.exists ? snap.data() ?? null : null;
160
+ if (existing?.status === "processed") return false;
161
+ if (existing?.status === "processing" && !webhookProcessingIsStale(existing.created_at)) return false;
162
+ tx.set(eventRef, {
163
+ event_id: cleanEventId,
164
+ status: "processing",
165
+ error_message: null,
166
+ processed_at: null,
167
+ created_at: timestamp(),
168
+ }, { merge: true });
169
+ return true;
170
+ });
171
+ if (!claimed) return "duplicate";
172
+
173
+ try {
174
+ await handler();
175
+ await eventRef.set({ status: "processed", error_message: null, processed_at: timestamp() }, { merge: true });
176
+ return "processed";
177
+ } catch (error) {
178
+ await eventRef.set({ status: "failed", error_message: errorMessage(error), processed_at: null }, { merge: true });
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ async findOrderByChallengeHash(challengeHash: string): Promise<{ id: string } | null> {
184
+ const snap = await this.attempts().where("challenge_hash", "==", challengeHash).limit(1).get();
185
+ const doc = snap.docs[0];
186
+ const row = doc?.data();
187
+ return row?.order_id ? { id: String(row.order_id) } : null;
188
+ }
189
+
190
+ async markOrderPaidOnce(input: {
191
+ order_id: string;
192
+ requirement_id: string;
193
+ chain_receipt_id: string;
194
+ }): Promise<void> {
195
+ await this.options.db.runTransaction(async (tx) => {
196
+ const activeSnap = await tx.get(this.activeRef(input.order_id));
197
+ if (!activeSnap.exists) return;
198
+ const active = activeSnap.data() ?? {};
199
+ if (isTerminal(active.status, "paid")) return;
200
+ tx.delete(this.activeRef(input.order_id));
201
+ tx.update(this.attemptRef(String(active.attempt_id)), {
202
+ status: "paid",
203
+ requirement_id: input.requirement_id,
204
+ chain_receipt_id: input.chain_receipt_id,
205
+ paid_at: timestamp(),
206
+ updated_at: timestamp(),
207
+ });
208
+ this.updateOrderStatusTx(tx, input.order_id, "paid");
209
+ });
210
+ }
211
+
212
+ async markOrderFulfilledUnsettledOnce(input: {
213
+ order_id: string;
214
+ requirement_id: string;
215
+ pricing_band: string;
216
+ }): Promise<void> {
217
+ await this.options.db.runTransaction(async (tx) => {
218
+ const activeSnap = await tx.get(this.activeRef(input.order_id));
219
+ if (!activeSnap.exists) return;
220
+ const active = activeSnap.data() ?? {};
221
+ if (isTerminal(active.status, "fulfilled_unsettled")) return;
222
+ tx.delete(this.activeRef(input.order_id));
223
+ tx.update(this.attemptRef(String(active.attempt_id)), {
224
+ status: "fulfilled_unsettled",
225
+ requirement_id: input.requirement_id,
226
+ pricing_band: input.pricing_band,
227
+ fulfilled_unsettled_at: timestamp(),
228
+ updated_at: timestamp(),
229
+ });
230
+ this.updateOrderStatusTx(tx, input.order_id, "fulfilled_unsettled");
231
+ });
232
+ }
233
+
234
+ async flagPaymentReview(input: Record<string, unknown>): Promise<void> {
235
+ await this.reviews().doc(`sdrp_review_${hash(`${Date.now()}:${JSON.stringify(input)}`).slice(0, 24)}`).set({
236
+ order_id: textOrNull(input.order_id),
237
+ reason: String(input.reason || "manual_review_required"),
238
+ payload_json: input,
239
+ created_at: timestamp(),
240
+ });
241
+ }
242
+
243
+ private async findProductOrder(orderId: string): Promise<Record<string, unknown> | null> {
244
+ const snap = await this.orders().doc(orderId).get();
245
+ if (!snap.exists) return null;
246
+ const row = snap.data() ?? {};
247
+ return {
248
+ ...row,
249
+ id: row[this.options.order_id_field] ?? snap.id,
250
+ amount_minor: row[this.options.amount_minor_field],
251
+ currency: row[this.options.currency_field],
252
+ };
253
+ }
254
+
255
+ private async nextAttemptNumber(orderId: string): Promise<number> {
256
+ const snap = await this.attempts().where("order_id", "==", orderId).get();
257
+ let current = 0;
258
+ for (const doc of snap.docs) {
259
+ const attemptNumber = Number(doc.data().attempt_number || 0);
260
+ if (Number.isSafeInteger(attemptNumber) && attemptNumber > current) current = attemptNumber;
261
+ }
262
+ return current > 0 ? current + 1 : 1;
263
+ }
264
+
265
+ private releaseAttemptHistoryTx(tx: Transaction, active: DocumentData, status: "expired" | "failed"): void {
266
+ const timestampField = status === "expired" ? "expires_at" : "failed_at";
267
+ tx.update(this.attemptRef(String(active.attempt_id)), {
268
+ status,
269
+ [timestampField]: active[timestampField] ?? timestamp(),
270
+ creation_owner_id: null,
271
+ creation_lease_expires_at: null,
272
+ updated_at: timestamp(),
273
+ });
274
+ }
275
+
276
+ private updateOrderStatusTx(tx: Transaction, orderId: string, status: string): void {
277
+ const patch: Record<string, unknown> = {};
278
+ if (this.options.order_status_field) patch[this.options.order_status_field] = status;
279
+ if (this.options.order_updated_at_field) patch[this.options.order_updated_at_field] = timestamp();
280
+ if (Object.keys(patch).length) tx.update(this.orders().doc(orderId), patch);
281
+ }
282
+
283
+ private toCheckoutAttempt(order: Record<string, unknown>, state: Record<string, unknown>, pending = false): SiglumeCheckoutAttempt {
284
+ return {
285
+ id: String(order.id),
286
+ order_id: String(order.id),
287
+ amount_minor: Number(order.amount_minor),
288
+ currency: String(order.currency),
289
+ attempt_number: Number(state.attempt_number || 1),
290
+ attempt_id: String(state.attempt_id),
291
+ stable_nonce: String(state.stable_nonce),
292
+ status: String(state.status || ""),
293
+ checkout_session_id: textOrUndefined(state.checkout_session_id),
294
+ checkout_url: textOrUndefined(state.checkout_url),
295
+ expires_at: timestampTextOrNull(state.expires_at),
296
+ checkout_creation_pending: pending,
297
+ };
298
+ }
299
+
300
+ private orders() {
301
+ return this.options.db.collection(this.options.orders_collection);
302
+ }
303
+
304
+ private attempts() {
305
+ return this.options.db.collection(this.options.checkout_attempts_collection);
306
+ }
307
+
308
+ private reviews() {
309
+ return this.options.db.collection(this.options.payment_reviews_collection);
310
+ }
311
+
312
+ private activeRef(orderId: string) {
313
+ return this.attempts().doc(`active_${hash(orderId).slice(0, 32)}`);
314
+ }
315
+
316
+ private attemptRef(attemptId: string) {
317
+ return this.attempts().doc(`attempt_${attemptId}`);
318
+ }
319
+
320
+ private eventRef(eventId: string) {
321
+ return this.options.db.collection(this.options.webhook_events_collection).doc(`event_${hash(eventId).slice(0, 32)}`);
322
+ }
323
+ }
324
+
325
+ function normalizeOptions(options: FirestoreSiglumeOrderStoreOptions): NormalizedOptions {
326
+ return {
327
+ db: options.db,
328
+ orders_collection: options.orders_collection ?? "orders",
329
+ order_id_field: options.order_id_field ?? "id",
330
+ amount_minor_field: options.amount_minor_field ?? "amount_minor",
331
+ currency_field: options.currency_field ?? "currency",
332
+ order_status_field: options.order_status_field === undefined ? "status" : options.order_status_field,
333
+ order_updated_at_field: options.order_updated_at_field === undefined ? "updated_at" : options.order_updated_at_field,
334
+ checkout_attempts_collection: options.checkout_attempts_collection ?? "siglume_checkout_attempts",
335
+ webhook_events_collection: options.webhook_events_collection ?? "siglume_webhook_events",
336
+ payment_reviews_collection: options.payment_reviews_collection ?? "siglume_payment_reviews",
337
+ authorize_order: options.authorize_order,
338
+ };
339
+ }
340
+
341
+ function stableAttempt(orderId: string, attemptNumber: number): { attempt_id: string; stable_nonce: string } {
342
+ const digest = hash(`${orderId}:${attemptNumber}`).slice(0, 32);
343
+ return {
344
+ attempt_id: `sdrp_attempt_${digest}`,
345
+ stable_nonce: `sdrp-${digest}`,
346
+ };
347
+ }
348
+
349
+ function hash(value: string): string {
350
+ return createHash("sha256").update(value).digest("hex");
351
+ }
352
+
353
+ function requireText(value: string, name: string): string {
354
+ const text = String(value || "").trim();
355
+ if (!text) throw new Error(`${name} is required.`);
356
+ return text;
357
+ }
358
+
359
+ function timestamp(value: number | string | Date = Date.now()): string {
360
+ const date = value instanceof Date ? value : new Date(value);
361
+ return date.toISOString();
362
+ }
363
+
364
+ function timestampOrNull(value: unknown): string | null {
365
+ if (!value) return null;
366
+ const parsed = Date.parse(String(value));
367
+ return Number.isFinite(parsed) ? timestamp(parsed) : null;
368
+ }
369
+
370
+ function timestampTextOrNull(value: unknown): string | null {
371
+ if (!value) return null;
372
+ return String(value);
373
+ }
374
+
375
+ function timestampHasPassed(value: unknown): boolean {
376
+ if (!value) return false;
377
+ const parsed = Date.parse(String(value));
378
+ return Number.isFinite(parsed) && parsed <= Date.now();
379
+ }
380
+
381
+ function webhookProcessingIsStale(value: unknown): boolean {
382
+ if (!value) return false;
383
+ const parsed = Date.parse(String(value));
384
+ return Number.isFinite(parsed) && Date.now() - parsed > WEBHOOK_PROCESSING_STALE_MS;
385
+ }
386
+
387
+ function isReusableCheckoutAttempt(state: Record<string, unknown>): boolean {
388
+ return String(state.status || "") === "pending"
389
+ && Boolean(textOrUndefined(state.checkout_session_id))
390
+ && Boolean(textOrUndefined(state.checkout_url))
391
+ && !timestampHasPassed(state.expires_at);
392
+ }
393
+
394
+ function isCreatingCheckoutAttempt(state: Record<string, unknown>): boolean {
395
+ return String(state.status || "") === "creating";
396
+ }
397
+
398
+ function isTerminal(value: unknown, currentTerminal: string): boolean {
399
+ const status = String(value || "");
400
+ return ["paid", "expired", "cancelled", "failed", currentTerminal].includes(status);
401
+ }
402
+
403
+ function textOrUndefined(value: unknown): string | undefined {
404
+ return typeof value === "string" && value ? value : undefined;
405
+ }
406
+
407
+ function textOrNull(value: unknown): string | null {
408
+ return typeof value === "string" && value ? value : null;
409
+ }
410
+
411
+ function errorMessage(error: unknown): string {
412
+ if (error instanceof Error) return error.message.slice(0, 1000);
413
+ return String(error || "webhook handler failed").slice(0, 1000);
414
+ }
415
+
416
+ function sleep(ms: number): Promise<void> {
417
+ return new Promise((resolve) => setTimeout(resolve, ms));
418
+ }