@siglume/direct-request-payment 0.4.23 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -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 +1 -1
- package/templates/express/siglume-order-store.sql.ts +229 -52
- package/templates/express/siglume-sdrp-routes.ts +43 -9
- package/templates/fastapi/README.md +32 -3
- package/templates/fastapi/siglume_order_store_example.py +15 -0
- package/templates/fastapi/siglume_order_store_sqlalchemy.py +238 -53
- package/templates/fastapi/siglume_order_store_sqlalchemy_async.py +496 -0
- package/templates/fastapi/siglume_sdrp_routes.py +31 -0
|
@@ -3,8 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import contextvars
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
|
+
import asyncio
|
|
6
7
|
import time
|
|
7
8
|
from collections.abc import Awaitable, Callable
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
8
10
|
from typing import Any, Literal
|
|
9
11
|
|
|
10
12
|
from fastapi import Request
|
|
@@ -43,13 +45,21 @@ orders = Table(
|
|
|
43
45
|
checkout_attempts = Table(
|
|
44
46
|
"siglume_checkout_attempts",
|
|
45
47
|
metadata,
|
|
46
|
-
Column("
|
|
47
|
-
Column("
|
|
48
|
+
Column("attempt_id", String(255), primary_key=True),
|
|
49
|
+
Column("order_id", String(255), nullable=False),
|
|
50
|
+
Column("attempt_number", BigInteger, nullable=False),
|
|
48
51
|
Column("stable_nonce", String(255), nullable=False, unique=True),
|
|
52
|
+
Column("active_key", String(255), unique=True),
|
|
49
53
|
Column("status", String(32), nullable=False, default="created"),
|
|
50
54
|
Column("challenge_hash", String(255), unique=True),
|
|
51
55
|
Column("checkout_session_id", String(255)),
|
|
52
56
|
Column("checkout_url", Text),
|
|
57
|
+
Column("expires_at", DateTime(timezone=True)),
|
|
58
|
+
Column("cancelled_at", DateTime(timezone=True)),
|
|
59
|
+
Column("failed_at", DateTime(timezone=True)),
|
|
60
|
+
Column("creation_owner_id", String(255)),
|
|
61
|
+
Column("creation_lease_expires_at", DateTime(timezone=True)),
|
|
62
|
+
Column("error_message", Text),
|
|
53
63
|
Column("requirement_id", String(255)),
|
|
54
64
|
Column("chain_receipt_id", String(255)),
|
|
55
65
|
Column("pricing_band", String(32)),
|
|
@@ -59,6 +69,10 @@ checkout_attempts = Table(
|
|
|
59
69
|
Column("updated_at", DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False),
|
|
60
70
|
)
|
|
61
71
|
|
|
72
|
+
CHECKOUT_CREATION_LEASE_SECONDS = 30
|
|
73
|
+
CHECKOUT_CREATION_WAIT_SECONDS = 10
|
|
74
|
+
CHECKOUT_CREATION_POLL_SECONDS = 0.1
|
|
75
|
+
|
|
62
76
|
webhook_events = Table(
|
|
63
77
|
"siglume_webhook_events",
|
|
64
78
|
metadata,
|
|
@@ -90,8 +104,11 @@ def create_sqlalchemy_engine(database_url: str) -> Engine:
|
|
|
90
104
|
return create_engine(database_url, future=True, connect_args=connect_args)
|
|
91
105
|
|
|
92
106
|
|
|
93
|
-
def create_sqlalchemy_siglume_schema(engine: Engine) -> None:
|
|
94
|
-
|
|
107
|
+
def create_sqlalchemy_siglume_schema(engine: Engine, *, include_orders_table: bool = False) -> None:
|
|
108
|
+
tables = [checkout_attempts, webhook_events, payment_reviews]
|
|
109
|
+
if include_orders_table:
|
|
110
|
+
tables.insert(0, orders)
|
|
111
|
+
metadata.create_all(engine, tables=tables)
|
|
95
112
|
|
|
96
113
|
|
|
97
114
|
def seed_sqlalchemy_order(
|
|
@@ -117,48 +134,120 @@ class SQLAlchemySiglumeOrderStore:
|
|
|
117
134
|
self,
|
|
118
135
|
session_factory: sessionmaker[Session],
|
|
119
136
|
*,
|
|
137
|
+
orders_table: Table = orders,
|
|
138
|
+
order_id_column: str = "id",
|
|
139
|
+
amount_minor_column: str = "amount_minor",
|
|
140
|
+
currency_column: str = "currency",
|
|
141
|
+
order_status_column: str | None = "status",
|
|
142
|
+
order_updated_at_column: str | None = "updated_at",
|
|
120
143
|
authorize_order: Callable[[dict[str, Any], Request], bool] | None = None,
|
|
121
144
|
) -> None:
|
|
122
145
|
self._session_factory = session_factory
|
|
146
|
+
self._orders_table = orders_table
|
|
147
|
+
self._order_id_column = orders_table.c[order_id_column]
|
|
148
|
+
self._amount_minor_column = orders_table.c[amount_minor_column]
|
|
149
|
+
self._currency_column = orders_table.c[currency_column]
|
|
150
|
+
self._order_status_column = orders_table.c[order_status_column] if order_status_column else None
|
|
151
|
+
self._order_updated_at_column = orders_table.c[order_updated_at_column] if order_updated_at_column else None
|
|
123
152
|
self._authorize_order = authorize_order
|
|
124
153
|
|
|
125
154
|
async def begin_checkout_attempt(self, order_id: str, request: Request) -> dict[str, Any] | None:
|
|
126
155
|
clean_order_id = _require_text(order_id, "order_id")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
session.execute(
|
|
141
|
-
|
|
142
|
-
order_id=clean_order_id,
|
|
143
|
-
attempt_id=attempt_id,
|
|
144
|
-
stable_nonce=stable_nonce,
|
|
145
|
-
status="created",
|
|
146
|
-
)
|
|
147
|
-
)
|
|
148
|
-
existing = session.execute(
|
|
149
|
-
select(checkout_attempts).where(checkout_attempts.c.order_id == clean_order_id)
|
|
156
|
+
wait_until = time.monotonic() + CHECKOUT_CREATION_WAIT_SECONDS
|
|
157
|
+
while True:
|
|
158
|
+
wait_attempt: dict[str, Any] | None = None
|
|
159
|
+
with self._session_factory.begin() as session:
|
|
160
|
+
order = session.execute(
|
|
161
|
+
select(self._orders_table).where(self._order_id_column == clean_order_id)
|
|
162
|
+
).mappings().first()
|
|
163
|
+
if order is None:
|
|
164
|
+
return None
|
|
165
|
+
order_dict = self._canonical_order_dict(order)
|
|
166
|
+
if self._authorize_order and not self._authorize_order(order_dict, request):
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
active = session.execute(
|
|
170
|
+
select(checkout_attempts).where(checkout_attempts.c.active_key == clean_order_id)
|
|
150
171
|
).mappings().first()
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
172
|
+
if active is not None:
|
|
173
|
+
state = dict(active)
|
|
174
|
+
if _is_reusable_checkout_attempt(state):
|
|
175
|
+
return _checkout_attempt_dict(order_dict, state)
|
|
176
|
+
if state.get("status") == "creating" and not _timestamp_has_passed(state.get("creation_lease_expires_at")):
|
|
177
|
+
pending = _checkout_attempt_dict(order_dict, state)
|
|
178
|
+
pending["checkout_creation_pending"] = True
|
|
179
|
+
wait_attempt = pending
|
|
180
|
+
else:
|
|
181
|
+
status = "expired" if state.get("status") == "pending" else "failed"
|
|
182
|
+
session.execute(
|
|
183
|
+
update(checkout_attempts)
|
|
184
|
+
.where(checkout_attempts.c.attempt_id == state["attempt_id"])
|
|
185
|
+
.values(
|
|
186
|
+
status=status,
|
|
187
|
+
active_key=None,
|
|
188
|
+
failed_at=func.now() if status == "failed" else state.get("failed_at"),
|
|
189
|
+
creation_owner_id=None,
|
|
190
|
+
creation_lease_expires_at=None,
|
|
191
|
+
updated_at=func.now(),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
if wait_attempt is not None:
|
|
195
|
+
pass
|
|
196
|
+
else:
|
|
197
|
+
active = None
|
|
198
|
+
if wait_attempt is not None:
|
|
199
|
+
if time.monotonic() >= wait_until:
|
|
200
|
+
return wait_attempt
|
|
201
|
+
should_wait = True
|
|
202
|
+
else:
|
|
203
|
+
should_wait = False
|
|
204
|
+
|
|
205
|
+
if should_wait:
|
|
206
|
+
# Leave the transaction before sleeping so the creator can commit.
|
|
207
|
+
pass
|
|
208
|
+
else:
|
|
209
|
+
max_attempt_number = session.execute(
|
|
210
|
+
select(func.max(checkout_attempts.c.attempt_number)).where(checkout_attempts.c.order_id == clean_order_id)
|
|
211
|
+
).scalar_one_or_none()
|
|
212
|
+
attempt_number = int(max_attempt_number or 0) + 1
|
|
213
|
+
attempt_id, stable_nonce = _stable_attempt(clean_order_id, attempt_number)
|
|
214
|
+
try:
|
|
215
|
+
session.execute(
|
|
216
|
+
insert(checkout_attempts).values(
|
|
217
|
+
order_id=clean_order_id,
|
|
218
|
+
attempt_number=attempt_number,
|
|
219
|
+
attempt_id=attempt_id,
|
|
220
|
+
stable_nonce=stable_nonce,
|
|
221
|
+
active_key=clean_order_id,
|
|
222
|
+
status="creating",
|
|
223
|
+
creation_owner_id=f"sdrp_create_{_sha256(f'{time.time()}:{clean_order_id}')[:24]}",
|
|
224
|
+
creation_lease_expires_at=_now_utc() + timedelta(seconds=CHECKOUT_CREATION_LEASE_SECONDS),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
except IntegrityError:
|
|
228
|
+
pending = _checkout_attempt_dict(order_dict, {
|
|
229
|
+
"order_id": clean_order_id,
|
|
230
|
+
"attempt_number": attempt_number,
|
|
231
|
+
"attempt_id": attempt_id,
|
|
232
|
+
"stable_nonce": stable_nonce,
|
|
233
|
+
"status": "creating",
|
|
234
|
+
})
|
|
235
|
+
pending["checkout_creation_pending"] = True
|
|
236
|
+
if time.monotonic() >= wait_until:
|
|
237
|
+
return pending
|
|
238
|
+
wait_attempt = pending
|
|
239
|
+
else:
|
|
240
|
+
return {
|
|
241
|
+
"id": clean_order_id,
|
|
242
|
+
"order_id": clean_order_id,
|
|
243
|
+
"amount_minor": int(order_dict["amount_minor"]),
|
|
244
|
+
"currency": str(order_dict["currency"]),
|
|
245
|
+
"attempt_number": attempt_number,
|
|
246
|
+
"attempt_id": attempt_id,
|
|
247
|
+
"stable_nonce": stable_nonce,
|
|
248
|
+
"status": "creating",
|
|
249
|
+
}
|
|
250
|
+
await asyncio.sleep(CHECKOUT_CREATION_POLL_SECONDS)
|
|
162
251
|
|
|
163
252
|
async def mark_checkout_pending(
|
|
164
253
|
self,
|
|
@@ -169,18 +258,49 @@ class SQLAlchemySiglumeOrderStore:
|
|
|
169
258
|
challenge_hash: str,
|
|
170
259
|
checkout_session_id: str,
|
|
171
260
|
checkout_url: str,
|
|
261
|
+
expires_at: str | None = None,
|
|
172
262
|
) -> None:
|
|
173
263
|
session = self._session()
|
|
174
264
|
session.execute(
|
|
175
265
|
update(checkout_attempts)
|
|
176
266
|
.where(checkout_attempts.c.order_id == order_id)
|
|
267
|
+
.where(checkout_attempts.c.attempt_id == attempt_id)
|
|
268
|
+
.where(checkout_attempts.c.status == "creating")
|
|
177
269
|
.values(
|
|
178
270
|
status="pending",
|
|
179
|
-
attempt_id=attempt_id,
|
|
180
271
|
stable_nonce=stable_nonce,
|
|
181
272
|
challenge_hash=challenge_hash,
|
|
182
273
|
checkout_session_id=checkout_session_id,
|
|
183
274
|
checkout_url=checkout_url,
|
|
275
|
+
expires_at=_parse_datetime(expires_at),
|
|
276
|
+
creation_owner_id=None,
|
|
277
|
+
creation_lease_expires_at=None,
|
|
278
|
+
error_message=None,
|
|
279
|
+
updated_at=func.now(),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
self._commit_if_own_session(session)
|
|
283
|
+
|
|
284
|
+
async def mark_checkout_failed(
|
|
285
|
+
self,
|
|
286
|
+
*,
|
|
287
|
+
order_id: str,
|
|
288
|
+
attempt_id: str,
|
|
289
|
+
error_message: str | None = None,
|
|
290
|
+
) -> None:
|
|
291
|
+
session = self._session()
|
|
292
|
+
session.execute(
|
|
293
|
+
update(checkout_attempts)
|
|
294
|
+
.where(checkout_attempts.c.order_id == order_id)
|
|
295
|
+
.where(checkout_attempts.c.attempt_id == attempt_id)
|
|
296
|
+
.where(checkout_attempts.c.status == "creating")
|
|
297
|
+
.values(
|
|
298
|
+
status="failed",
|
|
299
|
+
active_key=None,
|
|
300
|
+
failed_at=func.now(),
|
|
301
|
+
creation_owner_id=None,
|
|
302
|
+
creation_lease_expires_at=None,
|
|
303
|
+
error_message=(error_message or "checkout session creation failed")[:1000],
|
|
184
304
|
updated_at=func.now(),
|
|
185
305
|
)
|
|
186
306
|
)
|
|
@@ -227,9 +347,10 @@ class SQLAlchemySiglumeOrderStore:
|
|
|
227
347
|
result = session.execute(
|
|
228
348
|
update(checkout_attempts)
|
|
229
349
|
.where(checkout_attempts.c.order_id == order_id)
|
|
230
|
-
.where(checkout_attempts.c.status
|
|
350
|
+
.where(checkout_attempts.c.status.notin_(["paid", "expired", "cancelled", "failed"]))
|
|
231
351
|
.values(
|
|
232
352
|
status="paid",
|
|
353
|
+
active_key=None,
|
|
233
354
|
requirement_id=requirement_id,
|
|
234
355
|
chain_receipt_id=chain_receipt_id,
|
|
235
356
|
paid_at=func.now(),
|
|
@@ -237,11 +358,7 @@ class SQLAlchemySiglumeOrderStore:
|
|
|
237
358
|
)
|
|
238
359
|
)
|
|
239
360
|
if result.rowcount:
|
|
240
|
-
|
|
241
|
-
update(orders)
|
|
242
|
-
.where(orders.c.id == order_id)
|
|
243
|
-
.values(status="paid", updated_at=func.now())
|
|
244
|
-
)
|
|
361
|
+
self._mark_product_order_status(session, order_id, "paid")
|
|
245
362
|
self._commit_if_own_session(session)
|
|
246
363
|
|
|
247
364
|
async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None:
|
|
@@ -249,9 +366,10 @@ class SQLAlchemySiglumeOrderStore:
|
|
|
249
366
|
result = session.execute(
|
|
250
367
|
update(checkout_attempts)
|
|
251
368
|
.where(checkout_attempts.c.order_id == order_id)
|
|
252
|
-
.where(checkout_attempts.c.status.notin_(["fulfilled_unsettled", "paid"]))
|
|
369
|
+
.where(checkout_attempts.c.status.notin_(["fulfilled_unsettled", "paid", "expired", "cancelled", "failed"]))
|
|
253
370
|
.values(
|
|
254
371
|
status="fulfilled_unsettled",
|
|
372
|
+
active_key=None,
|
|
255
373
|
requirement_id=requirement_id,
|
|
256
374
|
pricing_band=pricing_band,
|
|
257
375
|
fulfilled_unsettled_at=func.now(),
|
|
@@ -259,11 +377,7 @@ class SQLAlchemySiglumeOrderStore:
|
|
|
259
377
|
)
|
|
260
378
|
)
|
|
261
379
|
if result.rowcount:
|
|
262
|
-
|
|
263
|
-
update(orders)
|
|
264
|
-
.where(orders.c.id == order_id)
|
|
265
|
-
.values(status="fulfilled_unsettled", updated_at=func.now())
|
|
266
|
-
)
|
|
380
|
+
self._mark_product_order_status(session, order_id, "fulfilled_unsettled")
|
|
267
381
|
self._commit_if_own_session(session)
|
|
268
382
|
|
|
269
383
|
async def flag_payment_review(self, data: dict[str, Any]) -> None:
|
|
@@ -296,12 +410,83 @@ class SQLAlchemySiglumeOrderStore:
|
|
|
296
410
|
if _current_session.get() is not session:
|
|
297
411
|
session.close()
|
|
298
412
|
|
|
413
|
+
def _canonical_order_dict(self, row: Any) -> dict[str, Any]:
|
|
414
|
+
return {
|
|
415
|
+
"id": row[self._order_id_column],
|
|
416
|
+
"amount_minor": row[self._amount_minor_column],
|
|
417
|
+
"currency": row[self._currency_column],
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
def _mark_product_order_status(self, session: Session, order_id: str, status: str) -> None:
|
|
421
|
+
if self._order_status_column is None:
|
|
422
|
+
return
|
|
423
|
+
values: dict[Any, Any] = {self._order_status_column: status}
|
|
424
|
+
if self._order_updated_at_column is not None:
|
|
425
|
+
values[self._order_updated_at_column] = func.now()
|
|
426
|
+
session.execute(
|
|
427
|
+
update(self._orders_table)
|
|
428
|
+
.where(self._order_id_column == order_id)
|
|
429
|
+
.values(values)
|
|
430
|
+
)
|
|
299
431
|
|
|
300
|
-
|
|
301
|
-
|
|
432
|
+
|
|
433
|
+
def _stable_attempt(order_id: str, attempt_number: int) -> tuple[str, str]:
|
|
434
|
+
digest = _sha256(f"{order_id}:{attempt_number}")[:32]
|
|
302
435
|
return f"sdrp_attempt_{digest}", f"sdrp-{digest}"
|
|
303
436
|
|
|
304
437
|
|
|
438
|
+
def _checkout_attempt_dict(order: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
|
|
439
|
+
return {
|
|
440
|
+
"id": str(order["id"]),
|
|
441
|
+
"order_id": str(order["id"]),
|
|
442
|
+
"amount_minor": int(order["amount_minor"]),
|
|
443
|
+
"currency": str(order["currency"]),
|
|
444
|
+
"attempt_number": int(state.get("attempt_number") or 1),
|
|
445
|
+
"attempt_id": str(state["attempt_id"]),
|
|
446
|
+
"stable_nonce": str(state["stable_nonce"]),
|
|
447
|
+
"status": str(state.get("status") or ""),
|
|
448
|
+
"checkout_session_id": state.get("checkout_session_id"),
|
|
449
|
+
"checkout_url": state.get("checkout_url"),
|
|
450
|
+
"expires_at": state.get("expires_at"),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _is_reusable_checkout_attempt(state: dict[str, Any]) -> bool:
|
|
455
|
+
return (
|
|
456
|
+
state.get("status") == "pending"
|
|
457
|
+
and bool(state.get("checkout_session_id"))
|
|
458
|
+
and bool(state.get("checkout_url"))
|
|
459
|
+
and not _timestamp_has_passed(state.get("expires_at"))
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _timestamp_has_passed(value: Any) -> bool:
|
|
464
|
+
if value is None:
|
|
465
|
+
return False
|
|
466
|
+
parsed = _parse_datetime(value)
|
|
467
|
+
if parsed is None:
|
|
468
|
+
return False
|
|
469
|
+
return parsed <= _now_utc()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
473
|
+
if value is None:
|
|
474
|
+
return None
|
|
475
|
+
if isinstance(value, datetime):
|
|
476
|
+
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
477
|
+
text = str(value).strip()
|
|
478
|
+
if not text:
|
|
479
|
+
return None
|
|
480
|
+
try:
|
|
481
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
482
|
+
except ValueError:
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _now_utc() -> datetime:
|
|
487
|
+
return datetime.now(timezone.utc)
|
|
488
|
+
|
|
489
|
+
|
|
305
490
|
def _sha256(value: str) -> str:
|
|
306
491
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
307
492
|
|