@siglume/direct-request-payment 0.4.20 → 0.4.22
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 +25 -0
- package/README.md +8 -3
- package/bin/siglume-sdrp.mjs +416 -5
- package/dist/index.cjs +13 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/docs/api-reference.md +3 -0
- package/docs/pricing.md +1 -1
- package/docs/quickstart-10-minutes.md +90 -9
- package/docs/sandbox.md +60 -0
- package/docs/troubleshooting.md +11 -3
- package/examples/hosted-checkout-python/pyproject.toml +1 -1
- package/package.json +10 -2
- package/templates/express/README.md +16 -2
- package/templates/express/siglume-order-store.sql.ts +585 -0
- package/templates/fastapi/README.md +18 -3
- package/templates/fastapi/siglume_order_store_sqlalchemy.py +313 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { Request } from "express";
|
|
4
|
+
|
|
5
|
+
import type { SiglumeCheckoutAttempt, SiglumeSdrpOrderStore } from "./siglume-sdrp-routes.js";
|
|
6
|
+
|
|
7
|
+
export type SiglumeSqlDialect = "postgres" | "mysql" | "sqlite";
|
|
8
|
+
export type SiglumeSqlParamStyle = "numbered" | "question";
|
|
9
|
+
|
|
10
|
+
export interface SiglumeSqlExecutor {
|
|
11
|
+
query<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
12
|
+
statement: string,
|
|
13
|
+
params?: readonly unknown[],
|
|
14
|
+
): Promise<T[]>;
|
|
15
|
+
execute(statement: string, params?: readonly unknown[]): Promise<unknown>;
|
|
16
|
+
transaction?<T>(handler: (executor: SiglumeSqlExecutor) => Promise<T>): Promise<T>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SiglumeSqlOrderStoreOptions {
|
|
20
|
+
executor: SiglumeSqlExecutor;
|
|
21
|
+
dialect?: SiglumeSqlDialect;
|
|
22
|
+
param_style?: SiglumeSqlParamStyle;
|
|
23
|
+
orders_table?: string;
|
|
24
|
+
order_id_column?: string;
|
|
25
|
+
amount_minor_column?: string;
|
|
26
|
+
currency_column?: string;
|
|
27
|
+
order_status_column?: string | null;
|
|
28
|
+
order_updated_at_column?: string | null;
|
|
29
|
+
checkout_attempts_table?: string;
|
|
30
|
+
webhook_events_table?: string;
|
|
31
|
+
payment_reviews_table?: string;
|
|
32
|
+
authorize_order?: (order: Record<string, unknown>, req: Request) => boolean | Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface NormalizedOptions {
|
|
36
|
+
executor: SiglumeSqlExecutor;
|
|
37
|
+
dialect: SiglumeSqlDialect;
|
|
38
|
+
param_style: SiglumeSqlParamStyle;
|
|
39
|
+
orders_table: string;
|
|
40
|
+
order_id_column: string;
|
|
41
|
+
amount_minor_column: string;
|
|
42
|
+
currency_column: string;
|
|
43
|
+
order_status_column: string | null;
|
|
44
|
+
order_updated_at_column: string | null;
|
|
45
|
+
checkout_attempts_table: string;
|
|
46
|
+
webhook_events_table: string;
|
|
47
|
+
payment_reviews_table: string;
|
|
48
|
+
authorize_order?: (order: Record<string, unknown>, req: Request) => boolean | Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SqlParts {
|
|
52
|
+
readonly qOrders: string;
|
|
53
|
+
readonly qOrderId: string;
|
|
54
|
+
readonly qAmountMinor: string;
|
|
55
|
+
readonly qCurrency: string;
|
|
56
|
+
readonly qOrderStatus: string | null;
|
|
57
|
+
readonly qOrderUpdatedAt: string | null;
|
|
58
|
+
readonly qAttempts: string;
|
|
59
|
+
readonly qEvents: string;
|
|
60
|
+
readonly qReviews: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createSqlSiglumeOrderStore(options: SiglumeSqlOrderStoreOptions): SiglumeSdrpOrderStore {
|
|
64
|
+
return new SqlSiglumeOrderStore(normalizeOptions(options));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createPrismaSiglumeOrderStore(prisma: unknown, options: Omit<SiglumeSqlOrderStoreOptions, "executor"> = {}): SiglumeSdrpOrderStore {
|
|
68
|
+
return createSqlSiglumeOrderStore({
|
|
69
|
+
...options,
|
|
70
|
+
executor: createPrismaSiglumeSqlExecutor(prisma),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createTypeOrmSiglumeOrderStore(dataSource: unknown, options: Omit<SiglumeSqlOrderStoreOptions, "executor"> = {}): SiglumeSdrpOrderStore {
|
|
75
|
+
return createSqlSiglumeOrderStore({
|
|
76
|
+
...options,
|
|
77
|
+
executor: createTypeOrmSiglumeSqlExecutor(dataSource),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createSequelizeSiglumeOrderStore(sequelize: unknown, options: Omit<SiglumeSqlOrderStoreOptions, "executor" | "param_style"> = {}): SiglumeSdrpOrderStore {
|
|
82
|
+
return createSqlSiglumeOrderStore({
|
|
83
|
+
...options,
|
|
84
|
+
param_style: "question",
|
|
85
|
+
executor: createSequelizeSiglumeSqlExecutor(sequelize),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createDrizzleSiglumeOrderStore(
|
|
90
|
+
db: unknown,
|
|
91
|
+
drizzleSql: unknown,
|
|
92
|
+
options: Omit<SiglumeSqlOrderStoreOptions, "executor" | "param_style"> = {},
|
|
93
|
+
): SiglumeSdrpOrderStore {
|
|
94
|
+
return createSqlSiglumeOrderStore({
|
|
95
|
+
...options,
|
|
96
|
+
param_style: "question",
|
|
97
|
+
executor: createDrizzleSiglumeSqlExecutor(db, drizzleSql),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createSiglumeSdrpSqlSchema(options: {
|
|
102
|
+
dialect?: SiglumeSqlDialect;
|
|
103
|
+
orders_table?: string;
|
|
104
|
+
order_id_column?: string;
|
|
105
|
+
amount_minor_column?: string;
|
|
106
|
+
currency_column?: string;
|
|
107
|
+
order_status_column?: string | null;
|
|
108
|
+
order_updated_at_column?: string | null;
|
|
109
|
+
checkout_attempts_table?: string;
|
|
110
|
+
webhook_events_table?: string;
|
|
111
|
+
payment_reviews_table?: string;
|
|
112
|
+
include_orders_table?: boolean;
|
|
113
|
+
} = {}): string[] {
|
|
114
|
+
const normalized = normalizeOptions({ executor: noopExecutor, ...options });
|
|
115
|
+
const parts = sqlParts(normalized);
|
|
116
|
+
const text = normalized.dialect === "mysql" ? "VARCHAR(255)" : "TEXT";
|
|
117
|
+
const bigInt = normalized.dialect === "sqlite" ? "INTEGER" : "BIGINT";
|
|
118
|
+
const timestamp = normalized.dialect === "postgres" ? "TIMESTAMPTZ" : "TIMESTAMP";
|
|
119
|
+
const now = normalized.dialect === "postgres" ? "CURRENT_TIMESTAMP" : "CURRENT_TIMESTAMP";
|
|
120
|
+
const json = normalized.dialect === "postgres" ? "JSONB" : normalized.dialect === "mysql" ? "JSON" : "TEXT";
|
|
121
|
+
const statements: string[] = [];
|
|
122
|
+
|
|
123
|
+
if (options.include_orders_table !== false) {
|
|
124
|
+
statements.push(`
|
|
125
|
+
CREATE TABLE IF NOT EXISTS ${parts.qOrders} (
|
|
126
|
+
${parts.qOrderId} ${text} PRIMARY KEY,
|
|
127
|
+
${parts.qAmountMinor} ${bigInt} NOT NULL,
|
|
128
|
+
${parts.qCurrency} ${text} NOT NULL,
|
|
129
|
+
${parts.qOrderStatus ?? quoteIdentifier("status", normalized.dialect)} ${text} NOT NULL DEFAULT 'created',
|
|
130
|
+
created_at ${timestamp} NOT NULL DEFAULT ${now},
|
|
131
|
+
updated_at ${timestamp} NOT NULL DEFAULT ${now}
|
|
132
|
+
)`.trim());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
statements.push(`
|
|
136
|
+
CREATE TABLE IF NOT EXISTS ${parts.qAttempts} (
|
|
137
|
+
order_id ${text} PRIMARY KEY,
|
|
138
|
+
attempt_id ${text} NOT NULL UNIQUE,
|
|
139
|
+
stable_nonce ${text} NOT NULL UNIQUE,
|
|
140
|
+
status ${text} NOT NULL DEFAULT 'created',
|
|
141
|
+
challenge_hash ${text} UNIQUE,
|
|
142
|
+
checkout_session_id ${text},
|
|
143
|
+
checkout_url ${text},
|
|
144
|
+
requirement_id ${text},
|
|
145
|
+
chain_receipt_id ${text},
|
|
146
|
+
pricing_band ${text},
|
|
147
|
+
paid_at ${timestamp},
|
|
148
|
+
fulfilled_unsettled_at ${timestamp},
|
|
149
|
+
created_at ${timestamp} NOT NULL DEFAULT ${now},
|
|
150
|
+
updated_at ${timestamp} NOT NULL DEFAULT ${now}
|
|
151
|
+
)`.trim());
|
|
152
|
+
if (normalized.dialect !== "mysql") {
|
|
153
|
+
statements.push(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier("idx_siglume_checkout_attempts_challenge", normalized.dialect)} ON ${parts.qAttempts} (challenge_hash)`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
statements.push(`
|
|
157
|
+
CREATE TABLE IF NOT EXISTS ${parts.qEvents} (
|
|
158
|
+
event_id ${text} PRIMARY KEY,
|
|
159
|
+
status ${text} NOT NULL,
|
|
160
|
+
error_message ${text},
|
|
161
|
+
created_at ${timestamp} NOT NULL DEFAULT ${now},
|
|
162
|
+
processed_at ${timestamp}
|
|
163
|
+
)`.trim());
|
|
164
|
+
|
|
165
|
+
statements.push(`
|
|
166
|
+
CREATE TABLE IF NOT EXISTS ${parts.qReviews} (
|
|
167
|
+
review_id ${text} PRIMARY KEY,
|
|
168
|
+
order_id ${text},
|
|
169
|
+
reason ${text} NOT NULL,
|
|
170
|
+
payload_json ${json} NOT NULL,
|
|
171
|
+
created_at ${timestamp} NOT NULL DEFAULT ${now}
|
|
172
|
+
)`.trim());
|
|
173
|
+
if (normalized.dialect !== "mysql") {
|
|
174
|
+
statements.push(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier("idx_siglume_payment_reviews_order", normalized.dialect)} ON ${parts.qReviews} (order_id)`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return statements;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function createPrismaSiglumeSqlExecutor(prisma: unknown): SiglumeSqlExecutor {
|
|
181
|
+
const client = prisma as {
|
|
182
|
+
$queryRawUnsafe?: (statement: string, ...params: unknown[]) => Promise<unknown>;
|
|
183
|
+
$executeRawUnsafe?: (statement: string, ...params: unknown[]) => Promise<unknown>;
|
|
184
|
+
$transaction?: <T>(handler: (tx: unknown) => Promise<T>) => Promise<T>;
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
async query<T extends Record<string, unknown> = Record<string, unknown>>(statement: string, params: readonly unknown[] = []): Promise<T[]> {
|
|
188
|
+
const rows = await client.$queryRawUnsafe?.(statement, ...params);
|
|
189
|
+
return normalizeRows(rows) as T[];
|
|
190
|
+
},
|
|
191
|
+
async execute(statement, params = []) {
|
|
192
|
+
return client.$executeRawUnsafe?.(statement, ...params);
|
|
193
|
+
},
|
|
194
|
+
async transaction(handler) {
|
|
195
|
+
if (!client.$transaction) return handler(createPrismaSiglumeSqlExecutor(client));
|
|
196
|
+
return client.$transaction((tx) => handler(createPrismaSiglumeSqlExecutor(tx)));
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createTypeOrmSiglumeSqlExecutor(dataSource: unknown): SiglumeSqlExecutor {
|
|
202
|
+
const source = dataSource as {
|
|
203
|
+
query?: (statement: string, params?: readonly unknown[]) => Promise<unknown>;
|
|
204
|
+
transaction?: <T>(handler: (manager: { query: (statement: string, params?: readonly unknown[]) => Promise<unknown> }) => Promise<T>) => Promise<T>;
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
async query<T extends Record<string, unknown> = Record<string, unknown>>(statement: string, params: readonly unknown[] = []): Promise<T[]> {
|
|
208
|
+
return normalizeRows(await source.query?.(statement, params)) as T[];
|
|
209
|
+
},
|
|
210
|
+
async execute(statement, params = []) {
|
|
211
|
+
return source.query?.(statement, params);
|
|
212
|
+
},
|
|
213
|
+
async transaction(handler) {
|
|
214
|
+
if (!source.transaction) return handler(createTypeOrmSiglumeSqlExecutor(source));
|
|
215
|
+
return source.transaction((manager) => handler(createTypeOrmSiglumeSqlExecutor(manager)));
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function createSequelizeSiglumeSqlExecutor(sequelize: unknown): SiglumeSqlExecutor {
|
|
221
|
+
const source = sequelize as {
|
|
222
|
+
query?: (statement: string, options?: Record<string, unknown>) => Promise<unknown>;
|
|
223
|
+
transaction?: <T>(handler: (transaction: unknown) => Promise<T>) => Promise<T>;
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
async query<T extends Record<string, unknown> = Record<string, unknown>>(statement: string, params: readonly unknown[] = []): Promise<T[]> {
|
|
227
|
+
const result = await source.query?.(statement, { replacements: [...params] });
|
|
228
|
+
return normalizeRows(Array.isArray(result) ? result[0] : result) as T[];
|
|
229
|
+
},
|
|
230
|
+
async execute(statement, params = []) {
|
|
231
|
+
const result = await source.query?.(statement, { replacements: [...params] });
|
|
232
|
+
return Array.isArray(result) ? result[1] : result;
|
|
233
|
+
},
|
|
234
|
+
async transaction(handler) {
|
|
235
|
+
if (!source.transaction) return handler(createSequelizeSiglumeSqlExecutor(source));
|
|
236
|
+
return source.transaction((transaction) => handler({
|
|
237
|
+
query: async <T extends Record<string, unknown> = Record<string, unknown>>(statement: string, params: readonly unknown[] = []): Promise<T[]> => {
|
|
238
|
+
const result = await source.query?.(statement, { replacements: [...params], transaction });
|
|
239
|
+
return normalizeRows(Array.isArray(result) ? result[0] : result) as T[];
|
|
240
|
+
},
|
|
241
|
+
execute: async (statement, params = []) => {
|
|
242
|
+
const result = await source.query?.(statement, { replacements: [...params], transaction });
|
|
243
|
+
return Array.isArray(result) ? result[1] : result;
|
|
244
|
+
},
|
|
245
|
+
}));
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function createDrizzleSiglumeSqlExecutor(db: unknown, drizzleSql: unknown): SiglumeSqlExecutor {
|
|
251
|
+
const database = db as {
|
|
252
|
+
execute?: (statement: unknown) => Promise<unknown>;
|
|
253
|
+
transaction?: <T>(handler: (tx: unknown) => Promise<T>) => Promise<T>;
|
|
254
|
+
};
|
|
255
|
+
const sqlTag = drizzleSql as {
|
|
256
|
+
(strings: TemplateStringsArray, ...params: unknown[]): unknown;
|
|
257
|
+
raw?: (value: string) => unknown;
|
|
258
|
+
join?: (items: unknown[], separator: unknown) => unknown;
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
async query<T extends Record<string, unknown> = Record<string, unknown>>(statement: string, params: readonly unknown[] = []): Promise<T[]> {
|
|
262
|
+
return normalizeRows(await database.execute?.(toDrizzleStatement(sqlTag, statement, params))) as T[];
|
|
263
|
+
},
|
|
264
|
+
async execute(statement, params = []) {
|
|
265
|
+
return database.execute?.(toDrizzleStatement(sqlTag, statement, params));
|
|
266
|
+
},
|
|
267
|
+
async transaction(handler) {
|
|
268
|
+
if (!database.transaction) return handler(createDrizzleSiglumeSqlExecutor(database, sqlTag));
|
|
269
|
+
return database.transaction((tx) => handler(createDrizzleSiglumeSqlExecutor(tx, sqlTag)));
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
class SqlSiglumeOrderStore implements SiglumeSdrpOrderStore {
|
|
275
|
+
private readonly tx = new AsyncLocalStorage<SiglumeSqlExecutor>();
|
|
276
|
+
|
|
277
|
+
constructor(private readonly options: NormalizedOptions) {}
|
|
278
|
+
|
|
279
|
+
async beginCheckoutAttempt(orderId: string, req: Request): Promise<SiglumeCheckoutAttempt | null> {
|
|
280
|
+
const cleanOrderId = requireText(orderId, "order_id");
|
|
281
|
+
return this.withTransaction(async () => {
|
|
282
|
+
const order = await this.findProductOrder(cleanOrderId);
|
|
283
|
+
if (!order) return null;
|
|
284
|
+
if (this.options.authorize_order && !(await this.options.authorize_order(order, req))) return null;
|
|
285
|
+
|
|
286
|
+
const parts = sqlParts(this.options);
|
|
287
|
+
const attempt = stableAttempt(cleanOrderId);
|
|
288
|
+
await this.insertAttemptIfMissing(cleanOrderId, attempt.attempt_id, attempt.stable_nonce);
|
|
289
|
+
const rows = await this.executor().query<Record<string, unknown>>(
|
|
290
|
+
`SELECT attempt_id, stable_nonce, checkout_session_id, checkout_url FROM ${parts.qAttempts} WHERE order_id = ${this.p(1)} LIMIT 1`,
|
|
291
|
+
[cleanOrderId],
|
|
292
|
+
);
|
|
293
|
+
const state = rows[0] ?? {};
|
|
294
|
+
return {
|
|
295
|
+
id: String(order.id),
|
|
296
|
+
order_id: String(order.id),
|
|
297
|
+
amount_minor: Number(order.amount_minor),
|
|
298
|
+
currency: String(order.currency),
|
|
299
|
+
attempt_id: String(state.attempt_id || attempt.attempt_id),
|
|
300
|
+
stable_nonce: String(state.stable_nonce || attempt.stable_nonce),
|
|
301
|
+
checkout_session_id: textOrUndefined(state.checkout_session_id),
|
|
302
|
+
checkout_url: textOrUndefined(state.checkout_url),
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async markCheckoutPending(input: {
|
|
308
|
+
order_id: string;
|
|
309
|
+
attempt_id: string;
|
|
310
|
+
stable_nonce: string;
|
|
311
|
+
challenge_hash: string;
|
|
312
|
+
checkout_session_id: string;
|
|
313
|
+
checkout_url: string;
|
|
314
|
+
}): Promise<void> {
|
|
315
|
+
const parts = sqlParts(this.options);
|
|
316
|
+
await this.executor().execute(
|
|
317
|
+
`UPDATE ${parts.qAttempts}
|
|
318
|
+
SET status = ${this.p(1)}, attempt_id = ${this.p(2)}, stable_nonce = ${this.p(3)}, challenge_hash = ${this.p(4)},
|
|
319
|
+
checkout_session_id = ${this.p(5)}, checkout_url = ${this.p(6)}, updated_at = CURRENT_TIMESTAMP
|
|
320
|
+
WHERE order_id = ${this.p(7)}`,
|
|
321
|
+
["pending", input.attempt_id, input.stable_nonce, input.challenge_hash, input.checkout_session_id, input.checkout_url, input.order_id],
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async processWebhookEventOnce(eventId: string, handler: () => Promise<void>): Promise<"processed" | "duplicate"> {
|
|
326
|
+
const cleanEventId = requireText(eventId, "event_id");
|
|
327
|
+
return this.withTransaction(async (executor) => {
|
|
328
|
+
const parts = sqlParts(this.options);
|
|
329
|
+
const existing = await executor.query(
|
|
330
|
+
`SELECT event_id FROM ${parts.qEvents} WHERE event_id = ${this.p(1)} LIMIT 1`,
|
|
331
|
+
[cleanEventId],
|
|
332
|
+
);
|
|
333
|
+
if (existing.length) return "duplicate";
|
|
334
|
+
const inserted = await this.executeChanged(
|
|
335
|
+
insertWebhookEventSql(this.options, cleanEventId),
|
|
336
|
+
[cleanEventId, "processing"],
|
|
337
|
+
);
|
|
338
|
+
if (inserted === 0) return "duplicate";
|
|
339
|
+
await this.tx.run(executor, handler);
|
|
340
|
+
await executor.execute(
|
|
341
|
+
`UPDATE ${parts.qEvents} SET status = ${this.p(1)}, processed_at = CURRENT_TIMESTAMP WHERE event_id = ${this.p(2)}`,
|
|
342
|
+
["processed", cleanEventId],
|
|
343
|
+
);
|
|
344
|
+
return "processed";
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async findOrderByChallengeHash(challengeHash: string): Promise<{ id: string } | null> {
|
|
349
|
+
const parts = sqlParts(this.options);
|
|
350
|
+
const rows = await this.executor().query<{ id: string }>(
|
|
351
|
+
`SELECT order_id AS id FROM ${parts.qAttempts} WHERE challenge_hash = ${this.p(1)} LIMIT 1`,
|
|
352
|
+
[challengeHash],
|
|
353
|
+
);
|
|
354
|
+
return rows[0] ?? null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async markOrderPaidOnce(input: {
|
|
358
|
+
order_id: string;
|
|
359
|
+
requirement_id: string;
|
|
360
|
+
chain_receipt_id: string;
|
|
361
|
+
}): Promise<void> {
|
|
362
|
+
const parts = sqlParts(this.options);
|
|
363
|
+
const changed = await this.executeChanged(
|
|
364
|
+
`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 <> ${this.p(5)}`,
|
|
367
|
+
["paid", input.requirement_id, input.chain_receipt_id, input.order_id, "paid"],
|
|
368
|
+
);
|
|
369
|
+
if (changed && parts.qOrderStatus) {
|
|
370
|
+
const updatedAt = parts.qOrderUpdatedAt ? `, ${parts.qOrderUpdatedAt} = CURRENT_TIMESTAMP` : "";
|
|
371
|
+
await this.executor().execute(
|
|
372
|
+
`UPDATE ${parts.qOrders} SET ${parts.qOrderStatus} = ${this.p(1)}${updatedAt} WHERE ${parts.qOrderId} = ${this.p(2)}`,
|
|
373
|
+
["paid", input.order_id],
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async markOrderFulfilledUnsettledOnce(input: {
|
|
379
|
+
order_id: string;
|
|
380
|
+
requirement_id: string;
|
|
381
|
+
pricing_band: string;
|
|
382
|
+
}): Promise<void> {
|
|
383
|
+
const parts = sqlParts(this.options);
|
|
384
|
+
const changed = await this.executeChanged(
|
|
385
|
+
`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"],
|
|
389
|
+
);
|
|
390
|
+
if (changed && parts.qOrderStatus) {
|
|
391
|
+
const updatedAt = parts.qOrderUpdatedAt ? `, ${parts.qOrderUpdatedAt} = CURRENT_TIMESTAMP` : "";
|
|
392
|
+
await this.executor().execute(
|
|
393
|
+
`UPDATE ${parts.qOrders} SET ${parts.qOrderStatus} = ${this.p(1)}${updatedAt} WHERE ${parts.qOrderId} = ${this.p(2)}`,
|
|
394
|
+
["fulfilled_unsettled", input.order_id],
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async flagPaymentReview(input: Record<string, unknown>): Promise<void> {
|
|
400
|
+
const parts = sqlParts(this.options);
|
|
401
|
+
await this.executor().execute(
|
|
402
|
+
`INSERT INTO ${parts.qReviews} (review_id, order_id, reason, payload_json, created_at)
|
|
403
|
+
VALUES (${this.p(1)}, ${this.p(2)}, ${this.p(3)}, ${this.p(4)}, CURRENT_TIMESTAMP)`,
|
|
404
|
+
[
|
|
405
|
+
`sdrp_review_${hash(`${Date.now()}:${JSON.stringify(input)}`).slice(0, 24)}`,
|
|
406
|
+
textOrNull(input.order_id),
|
|
407
|
+
String(input.reason || "manual_review_required"),
|
|
408
|
+
JSON.stringify(input),
|
|
409
|
+
],
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private async findProductOrder(orderId: string): Promise<Record<string, unknown> | null> {
|
|
414
|
+
const parts = sqlParts(this.options);
|
|
415
|
+
const rows = await this.executor().query<Record<string, unknown>>(
|
|
416
|
+
`SELECT ${parts.qOrderId} AS id, ${parts.qAmountMinor} AS amount_minor, ${parts.qCurrency} AS currency
|
|
417
|
+
FROM ${parts.qOrders}
|
|
418
|
+
WHERE ${parts.qOrderId} = ${this.p(1)}
|
|
419
|
+
LIMIT 1`,
|
|
420
|
+
[orderId],
|
|
421
|
+
);
|
|
422
|
+
return rows[0] ?? null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private async insertAttemptIfMissing(orderId: string, attemptId: string, stableNonce: string): Promise<void> {
|
|
426
|
+
await this.executor().execute(insertAttemptSql(this.options), [orderId, attemptId, stableNonce, "created"]);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private async executeChanged(statement: string, params: readonly unknown[]): Promise<number | null> {
|
|
430
|
+
const result = await this.executor().execute(statement, params);
|
|
431
|
+
return affectedRows(result);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private async withTransaction<T>(handler: (executor: SiglumeSqlExecutor) => Promise<T>): Promise<T> {
|
|
435
|
+
const current = this.tx.getStore();
|
|
436
|
+
if (current) return handler(current);
|
|
437
|
+
if (!this.options.executor.transaction) return handler(this.options.executor);
|
|
438
|
+
return this.options.executor.transaction((executor) => this.tx.run(executor, () => handler(executor)));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private executor(): SiglumeSqlExecutor {
|
|
442
|
+
return this.tx.getStore() ?? this.options.executor;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private p(index: number): string {
|
|
446
|
+
return placeholder(index, this.options);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function normalizeOptions(options: SiglumeSqlOrderStoreOptions): NormalizedOptions {
|
|
451
|
+
const dialect = options.dialect ?? "postgres";
|
|
452
|
+
return {
|
|
453
|
+
executor: options.executor,
|
|
454
|
+
dialect,
|
|
455
|
+
param_style: options.param_style ?? (dialect === "postgres" ? "numbered" : "question"),
|
|
456
|
+
orders_table: options.orders_table ?? "orders",
|
|
457
|
+
order_id_column: options.order_id_column ?? "id",
|
|
458
|
+
amount_minor_column: options.amount_minor_column ?? "amount_minor",
|
|
459
|
+
currency_column: options.currency_column ?? "currency",
|
|
460
|
+
order_status_column: options.order_status_column === undefined ? "status" : options.order_status_column,
|
|
461
|
+
order_updated_at_column: options.order_updated_at_column === undefined ? "updated_at" : options.order_updated_at_column,
|
|
462
|
+
checkout_attempts_table: options.checkout_attempts_table ?? "siglume_checkout_attempts",
|
|
463
|
+
webhook_events_table: options.webhook_events_table ?? "siglume_webhook_events",
|
|
464
|
+
payment_reviews_table: options.payment_reviews_table ?? "siglume_payment_reviews",
|
|
465
|
+
authorize_order: options.authorize_order,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function sqlParts(options: NormalizedOptions): SqlParts {
|
|
470
|
+
return {
|
|
471
|
+
qOrders: quoteIdentifier(options.orders_table, options.dialect),
|
|
472
|
+
qOrderId: quoteIdentifier(options.order_id_column, options.dialect),
|
|
473
|
+
qAmountMinor: quoteIdentifier(options.amount_minor_column, options.dialect),
|
|
474
|
+
qCurrency: quoteIdentifier(options.currency_column, options.dialect),
|
|
475
|
+
qOrderStatus: options.order_status_column ? quoteIdentifier(options.order_status_column, options.dialect) : null,
|
|
476
|
+
qOrderUpdatedAt: options.order_updated_at_column ? quoteIdentifier(options.order_updated_at_column, options.dialect) : null,
|
|
477
|
+
qAttempts: quoteIdentifier(options.checkout_attempts_table, options.dialect),
|
|
478
|
+
qEvents: quoteIdentifier(options.webhook_events_table, options.dialect),
|
|
479
|
+
qReviews: quoteIdentifier(options.payment_reviews_table, options.dialect),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function insertAttemptSql(options: NormalizedOptions): string {
|
|
484
|
+
const parts = sqlParts(options);
|
|
485
|
+
if (options.dialect === "mysql") {
|
|
486
|
+
return `INSERT IGNORE INTO ${parts.qAttempts} (order_id, attempt_id, stable_nonce, status) VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, ${placeholder(3, options)}, ${placeholder(4, options)})`;
|
|
487
|
+
}
|
|
488
|
+
return `INSERT INTO ${parts.qAttempts} (order_id, attempt_id, stable_nonce, status) VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, ${placeholder(3, options)}, ${placeholder(4, options)}) ON CONFLICT (order_id) DO NOTHING`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function insertWebhookEventSql(options: NormalizedOptions, _eventId: string): string {
|
|
492
|
+
const parts = sqlParts(options);
|
|
493
|
+
if (options.dialect === "mysql") {
|
|
494
|
+
return `INSERT IGNORE INTO ${parts.qEvents} (event_id, status, created_at) VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, CURRENT_TIMESTAMP)`;
|
|
495
|
+
}
|
|
496
|
+
return `INSERT INTO ${parts.qEvents} (event_id, status, created_at) VALUES (${placeholder(1, options)}, ${placeholder(2, options)}, CURRENT_TIMESTAMP) ON CONFLICT (event_id) DO NOTHING`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function placeholder(index: number, options: NormalizedOptions): string {
|
|
500
|
+
return options.param_style === "numbered" ? `$${index}` : "?";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function quoteIdentifier(identifier: string, dialect: SiglumeSqlDialect): string {
|
|
504
|
+
const quote = dialect === "mysql" ? "`" : "\"";
|
|
505
|
+
return identifier.split(".").map((part) => {
|
|
506
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(part)) {
|
|
507
|
+
throw new Error(`Unsafe SQL identifier: ${identifier}`);
|
|
508
|
+
}
|
|
509
|
+
return `${quote}${part}${quote}`;
|
|
510
|
+
}).join(".");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function stableAttempt(orderId: string): { attempt_id: string; stable_nonce: string } {
|
|
514
|
+
const digest = hash(orderId).slice(0, 32);
|
|
515
|
+
return {
|
|
516
|
+
attempt_id: `sdrp_attempt_${digest}`,
|
|
517
|
+
stable_nonce: `sdrp-${digest}`,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function hash(value: string): string {
|
|
522
|
+
return createHash("sha256").update(value).digest("hex");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function requireText(value: string, name: string): string {
|
|
526
|
+
const text = String(value || "").trim();
|
|
527
|
+
if (!text) throw new Error(`${name} is required.`);
|
|
528
|
+
return text;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function textOrUndefined(value: unknown): string | undefined {
|
|
532
|
+
return typeof value === "string" && value ? value : undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function textOrNull(value: unknown): string | null {
|
|
536
|
+
return typeof value === "string" && value ? value : null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function normalizeRows(value: unknown): Record<string, unknown>[] {
|
|
540
|
+
if (Array.isArray(value)) return value as Record<string, unknown>[];
|
|
541
|
+
if (value && typeof value === "object" && Array.isArray((value as { rows?: unknown[] }).rows)) {
|
|
542
|
+
return (value as { rows: Record<string, unknown>[] }).rows;
|
|
543
|
+
}
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function affectedRows(value: unknown): number | null {
|
|
548
|
+
if (typeof value === "number") return value;
|
|
549
|
+
if (!value || typeof value !== "object") return null;
|
|
550
|
+
const record = value as Record<string, unknown>;
|
|
551
|
+
for (const key of ["rowCount", "affectedRows", "changes"]) {
|
|
552
|
+
if (typeof record[key] === "number") return record[key] as number;
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function toDrizzleStatement(
|
|
558
|
+
sqlTag: {
|
|
559
|
+
(strings: TemplateStringsArray, ...params: unknown[]): unknown;
|
|
560
|
+
raw?: (value: string) => unknown;
|
|
561
|
+
join?: (items: unknown[], separator: unknown) => unknown;
|
|
562
|
+
},
|
|
563
|
+
statement: string,
|
|
564
|
+
params: readonly unknown[],
|
|
565
|
+
): unknown {
|
|
566
|
+
if (!sqlTag.raw || !sqlTag.join) {
|
|
567
|
+
throw new Error("Drizzle adapter requires the drizzle-orm sql tag.");
|
|
568
|
+
}
|
|
569
|
+
const fragments = statement.split("?");
|
|
570
|
+
const chunks: unknown[] = [];
|
|
571
|
+
fragments.forEach((fragment, index) => {
|
|
572
|
+
if (fragment) chunks.push(sqlTag.raw?.(fragment));
|
|
573
|
+
if (index < params.length) chunks.push(sqlTag`${params[index]}`);
|
|
574
|
+
});
|
|
575
|
+
return sqlTag.join(chunks, sqlTag.raw(""));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const noopExecutor: SiglumeSqlExecutor = {
|
|
579
|
+
async query() {
|
|
580
|
+
return [];
|
|
581
|
+
},
|
|
582
|
+
async execute() {
|
|
583
|
+
return 0;
|
|
584
|
+
},
|
|
585
|
+
};
|
|
@@ -5,17 +5,32 @@ Mount the router in your existing app:
|
|
|
5
5
|
```py
|
|
6
6
|
from fastapi import FastAPI
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import os
|
|
9
|
+
from sqlalchemy.orm import sessionmaker
|
|
10
|
+
|
|
11
|
+
from .siglume.siglume_order_store_sqlalchemy import (
|
|
12
|
+
SQLAlchemySiglumeOrderStore,
|
|
13
|
+
create_sqlalchemy_engine,
|
|
14
|
+
create_sqlalchemy_siglume_schema,
|
|
15
|
+
)
|
|
9
16
|
from .siglume.siglume_sdrp_routes import create_siglume_sdrp_router
|
|
10
17
|
|
|
11
18
|
app = FastAPI()
|
|
19
|
+
engine = create_sqlalchemy_engine(os.environ["DATABASE_URL"])
|
|
20
|
+
create_sqlalchemy_siglume_schema(engine)
|
|
21
|
+
SessionLocal = sessionmaker(engine, future=True)
|
|
22
|
+
siglume_order_store = SQLAlchemySiglumeOrderStore(SessionLocal)
|
|
23
|
+
|
|
12
24
|
app.include_router(
|
|
13
|
-
create_siglume_sdrp_router(
|
|
25
|
+
create_siglume_sdrp_router(siglume_order_store, allow_metered_payments=False),
|
|
14
26
|
prefix="/payments",
|
|
15
27
|
)
|
|
16
28
|
```
|
|
17
29
|
|
|
18
|
-
|
|
30
|
+
Use `siglume_order_store_sqlalchemy.py` for a durable SQLAlchemy adapter. It
|
|
31
|
+
creates the required checkout attempt, webhook event, and payment review tables
|
|
32
|
+
and keeps webhook processing transactional.
|
|
33
|
+
|
|
19
34
|
Keep `process_webhook_event_once()` transactional: record the webhook event as
|
|
20
35
|
processed only after the order update or review write succeeds. The generated
|
|
21
36
|
route defaults to Standard-only. Enable `allow_metered_payments` only after you
|