@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.
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextvars
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from collections.abc import Callable
9
+ from datetime import timedelta
10
+ from typing import Any, Awaitable, Literal
11
+
12
+ from fastapi import Request
13
+ from sqlalchemy import insert, select, update
14
+ from sqlalchemy.exc import IntegrityError
15
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
16
+ from sqlalchemy.sql import func
17
+
18
+ from .siglume_order_store_sqlalchemy import (
19
+ CHECKOUT_CREATION_LEASE_SECONDS,
20
+ CHECKOUT_CREATION_POLL_SECONDS,
21
+ CHECKOUT_CREATION_WAIT_SECONDS,
22
+ checkout_attempts,
23
+ metadata,
24
+ orders,
25
+ payment_reviews,
26
+ webhook_events,
27
+ )
28
+
29
+
30
+ _current_async_session: contextvars.ContextVar[AsyncSession | None] = contextvars.ContextVar(
31
+ "siglume_sdrp_async_sqlalchemy_session",
32
+ default=None,
33
+ )
34
+
35
+
36
+ def create_async_sqlalchemy_engine(database_url: str) -> AsyncEngine:
37
+ return create_async_engine(database_url, future=True)
38
+
39
+
40
+ async def create_async_sqlalchemy_siglume_schema(engine: AsyncEngine, *, include_orders_table: bool = False) -> None:
41
+ tables = [checkout_attempts, webhook_events, payment_reviews]
42
+ if include_orders_table:
43
+ tables.insert(0, orders)
44
+ async with engine.begin() as connection:
45
+ await connection.run_sync(lambda sync_connection: metadata.create_all(sync_connection, tables=tables))
46
+
47
+
48
+ async def seed_async_sqlalchemy_order(
49
+ session: AsyncSession,
50
+ *,
51
+ order_id: str,
52
+ amount_minor: int,
53
+ currency: str,
54
+ status: str = "created",
55
+ ) -> None:
56
+ await session.execute(
57
+ insert(orders).values(
58
+ id=order_id,
59
+ amount_minor=amount_minor,
60
+ currency=currency.upper(),
61
+ status=status,
62
+ )
63
+ )
64
+
65
+
66
+ class AsyncSQLAlchemySiglumeOrderStore:
67
+ def __init__(
68
+ self,
69
+ session_factory: async_sessionmaker[AsyncSession],
70
+ *,
71
+ orders_table: Any = orders,
72
+ order_id_column: str = "id",
73
+ amount_minor_column: str = "amount_minor",
74
+ currency_column: str = "currency",
75
+ order_status_column: str | None = "status",
76
+ order_updated_at_column: str | None = "updated_at",
77
+ authorize_order: Callable[[dict[str, Any], Request], bool] | None = None,
78
+ ) -> None:
79
+ self._session_factory = session_factory
80
+ self._orders_table = orders_table
81
+ self._order_id_column = orders_table.c[order_id_column]
82
+ self._amount_minor_column = orders_table.c[amount_minor_column]
83
+ self._currency_column = orders_table.c[currency_column]
84
+ self._order_status_column = orders_table.c[order_status_column] if order_status_column else None
85
+ self._order_updated_at_column = orders_table.c[order_updated_at_column] if order_updated_at_column else None
86
+ self._authorize_order = authorize_order
87
+
88
+ async def begin_checkout_attempt(self, order_id: str, request: Request) -> dict[str, Any] | None:
89
+ clean_order_id = _require_text(order_id, "order_id")
90
+ wait_until = time.monotonic() + CHECKOUT_CREATION_WAIT_SECONDS
91
+
92
+ while True:
93
+ wait_attempt: dict[str, Any] | None = None
94
+ try:
95
+ async with self._session_factory.begin() as session:
96
+ order = (
97
+ await session.execute(
98
+ select(self._orders_table).where(self._order_id_column == clean_order_id)
99
+ )
100
+ ).mappings().first()
101
+ if order is None:
102
+ return None
103
+ order_dict = self._canonical_order_dict(order)
104
+ if self._authorize_order and not self._authorize_order(order_dict, request):
105
+ return None
106
+
107
+ active = (
108
+ await session.execute(
109
+ select(checkout_attempts).where(checkout_attempts.c.active_key == clean_order_id)
110
+ )
111
+ ).mappings().first()
112
+ if active is not None:
113
+ state = dict(active)
114
+ if _is_reusable_checkout_attempt(state):
115
+ return _checkout_attempt_dict(order_dict, state)
116
+ if state.get("status") == "creating" and not _timestamp_has_passed(state.get("creation_lease_expires_at")):
117
+ wait_attempt = _checkout_attempt_dict(order_dict, state)
118
+ wait_attempt["checkout_creation_pending"] = True
119
+ else:
120
+ status = "expired" if state.get("status") == "pending" else "failed"
121
+ await session.execute(
122
+ update(checkout_attempts)
123
+ .where(checkout_attempts.c.attempt_id == state["attempt_id"])
124
+ .values(
125
+ status=status,
126
+ active_key=None,
127
+ failed_at=func.now() if status == "failed" else state.get("failed_at"),
128
+ creation_owner_id=None,
129
+ creation_lease_expires_at=None,
130
+ updated_at=func.now(),
131
+ )
132
+ )
133
+
134
+ if wait_attempt is None:
135
+ max_attempt_number = (
136
+ await session.execute(
137
+ select(func.max(checkout_attempts.c.attempt_number)).where(
138
+ checkout_attempts.c.order_id == clean_order_id
139
+ )
140
+ )
141
+ ).scalar_one_or_none()
142
+ attempt_number = int(max_attempt_number or 0) + 1
143
+ attempt_id, stable_nonce = _stable_attempt(clean_order_id, attempt_number)
144
+ await session.execute(
145
+ insert(checkout_attempts).values(
146
+ order_id=clean_order_id,
147
+ attempt_number=attempt_number,
148
+ attempt_id=attempt_id,
149
+ stable_nonce=stable_nonce,
150
+ active_key=clean_order_id,
151
+ status="creating",
152
+ creation_owner_id=f"sdrp_create_{_sha256(f'{time.time()}:{clean_order_id}')[:24]}",
153
+ creation_lease_expires_at=_now_utc() + timedelta(seconds=CHECKOUT_CREATION_LEASE_SECONDS),
154
+ )
155
+ )
156
+ return {
157
+ "id": clean_order_id,
158
+ "order_id": clean_order_id,
159
+ "amount_minor": int(order_dict["amount_minor"]),
160
+ "currency": str(order_dict["currency"]),
161
+ "attempt_number": attempt_number,
162
+ "attempt_id": attempt_id,
163
+ "stable_nonce": stable_nonce,
164
+ "status": "creating",
165
+ }
166
+ except IntegrityError:
167
+ wait_attempt = None
168
+
169
+ if wait_attempt is not None and time.monotonic() >= wait_until:
170
+ return wait_attempt
171
+ await asyncio.sleep(CHECKOUT_CREATION_POLL_SECONDS)
172
+
173
+ async def mark_checkout_pending(
174
+ self,
175
+ *,
176
+ order_id: str,
177
+ attempt_id: str,
178
+ stable_nonce: str,
179
+ challenge_hash: str,
180
+ checkout_session_id: str,
181
+ checkout_url: str,
182
+ expires_at: str | None = None,
183
+ ) -> None:
184
+ session = _current_async_session.get()
185
+ if session is not None:
186
+ await self._mark_checkout_pending(
187
+ session,
188
+ order_id=order_id,
189
+ attempt_id=attempt_id,
190
+ stable_nonce=stable_nonce,
191
+ challenge_hash=challenge_hash,
192
+ checkout_session_id=checkout_session_id,
193
+ checkout_url=checkout_url,
194
+ expires_at=expires_at,
195
+ )
196
+ return
197
+ async with self._session_factory.begin() as own_session:
198
+ await self._mark_checkout_pending(
199
+ own_session,
200
+ order_id=order_id,
201
+ attempt_id=attempt_id,
202
+ stable_nonce=stable_nonce,
203
+ challenge_hash=challenge_hash,
204
+ checkout_session_id=checkout_session_id,
205
+ checkout_url=checkout_url,
206
+ expires_at=expires_at,
207
+ )
208
+
209
+ async def mark_checkout_failed(
210
+ self,
211
+ *,
212
+ order_id: str,
213
+ attempt_id: str,
214
+ error_message: str | None = None,
215
+ ) -> None:
216
+ session = _current_async_session.get()
217
+ if session is not None:
218
+ await self._mark_checkout_failed(session, order_id=order_id, attempt_id=attempt_id, error_message=error_message)
219
+ return
220
+ async with self._session_factory.begin() as own_session:
221
+ await self._mark_checkout_failed(own_session, order_id=order_id, attempt_id=attempt_id, error_message=error_message)
222
+
223
+ async def process_webhook_event_once(
224
+ self,
225
+ event_id: str,
226
+ handler: Callable[[], Awaitable[None]],
227
+ ) -> Literal["processed", "duplicate"]:
228
+ clean_event_id = _require_text(event_id, "event_id")
229
+ async with self._session_factory.begin() as session:
230
+ existing = (
231
+ await session.execute(select(webhook_events.c.event_id).where(webhook_events.c.event_id == clean_event_id))
232
+ ).first()
233
+ if existing is not None:
234
+ return "duplicate"
235
+ try:
236
+ await session.execute(insert(webhook_events).values(event_id=clean_event_id, status="processing"))
237
+ except IntegrityError:
238
+ return "duplicate"
239
+
240
+ token = _current_async_session.set(session)
241
+ try:
242
+ await handler()
243
+ await session.execute(
244
+ update(webhook_events)
245
+ .where(webhook_events.c.event_id == clean_event_id)
246
+ .values(status="processed", processed_at=func.now())
247
+ )
248
+ return "processed"
249
+ finally:
250
+ _current_async_session.reset(token)
251
+
252
+ async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None:
253
+ session = _current_async_session.get()
254
+ if session is not None:
255
+ row = (
256
+ await session.execute(
257
+ select(checkout_attempts.c.order_id).where(checkout_attempts.c.challenge_hash == challenge_hash)
258
+ )
259
+ ).first()
260
+ return {"id": row[0]} if row else None
261
+ async with self._session_factory() as own_session:
262
+ row = (
263
+ await own_session.execute(
264
+ select(checkout_attempts.c.order_id).where(checkout_attempts.c.challenge_hash == challenge_hash)
265
+ )
266
+ ).first()
267
+ return {"id": row[0]} if row else None
268
+
269
+ async def mark_order_paid_once(self, *, order_id: str, requirement_id: str, chain_receipt_id: str) -> None:
270
+ session = _current_async_session.get()
271
+ if session is not None:
272
+ await self._mark_order_paid_once(session, order_id=order_id, requirement_id=requirement_id, chain_receipt_id=chain_receipt_id)
273
+ return
274
+ async with self._session_factory.begin() as own_session:
275
+ await self._mark_order_paid_once(own_session, order_id=order_id, requirement_id=requirement_id, chain_receipt_id=chain_receipt_id)
276
+
277
+ async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None:
278
+ session = _current_async_session.get()
279
+ if session is not None:
280
+ await self._mark_order_fulfilled_unsettled_once(
281
+ session,
282
+ order_id=order_id,
283
+ requirement_id=requirement_id,
284
+ pricing_band=pricing_band,
285
+ )
286
+ return
287
+ async with self._session_factory.begin() as own_session:
288
+ await self._mark_order_fulfilled_unsettled_once(
289
+ own_session,
290
+ order_id=order_id,
291
+ requirement_id=requirement_id,
292
+ pricing_band=pricing_band,
293
+ )
294
+
295
+ async def flag_payment_review(self, data: dict[str, Any]) -> None:
296
+ session = _current_async_session.get()
297
+ if session is not None:
298
+ await self._flag_payment_review(session, data)
299
+ return
300
+ async with self._session_factory.begin() as own_session:
301
+ await self._flag_payment_review(own_session, data)
302
+
303
+ async def _mark_checkout_pending(
304
+ self,
305
+ session: AsyncSession,
306
+ *,
307
+ order_id: str,
308
+ attempt_id: str,
309
+ stable_nonce: str,
310
+ challenge_hash: str,
311
+ checkout_session_id: str,
312
+ checkout_url: str,
313
+ expires_at: str | None,
314
+ ) -> None:
315
+ await session.execute(
316
+ update(checkout_attempts)
317
+ .where(checkout_attempts.c.order_id == order_id)
318
+ .where(checkout_attempts.c.attempt_id == attempt_id)
319
+ .where(checkout_attempts.c.status == "creating")
320
+ .values(
321
+ status="pending",
322
+ stable_nonce=stable_nonce,
323
+ challenge_hash=challenge_hash,
324
+ checkout_session_id=checkout_session_id,
325
+ checkout_url=checkout_url,
326
+ expires_at=_parse_datetime(expires_at),
327
+ creation_owner_id=None,
328
+ creation_lease_expires_at=None,
329
+ error_message=None,
330
+ updated_at=func.now(),
331
+ )
332
+ )
333
+
334
+ async def _mark_checkout_failed(
335
+ self,
336
+ session: AsyncSession,
337
+ *,
338
+ order_id: str,
339
+ attempt_id: str,
340
+ error_message: str | None,
341
+ ) -> None:
342
+ await session.execute(
343
+ update(checkout_attempts)
344
+ .where(checkout_attempts.c.order_id == order_id)
345
+ .where(checkout_attempts.c.attempt_id == attempt_id)
346
+ .where(checkout_attempts.c.status == "creating")
347
+ .values(
348
+ status="failed",
349
+ active_key=None,
350
+ failed_at=func.now(),
351
+ creation_owner_id=None,
352
+ creation_lease_expires_at=None,
353
+ error_message=(error_message or "checkout session creation failed")[:1000],
354
+ updated_at=func.now(),
355
+ )
356
+ )
357
+
358
+ async def _mark_order_paid_once(
359
+ self,
360
+ session: AsyncSession,
361
+ *,
362
+ order_id: str,
363
+ requirement_id: str,
364
+ chain_receipt_id: str,
365
+ ) -> None:
366
+ result = await session.execute(
367
+ update(checkout_attempts)
368
+ .where(checkout_attempts.c.order_id == order_id)
369
+ .where(checkout_attempts.c.status.notin_(["paid", "expired", "cancelled", "failed"]))
370
+ .values(
371
+ status="paid",
372
+ active_key=None,
373
+ requirement_id=requirement_id,
374
+ chain_receipt_id=chain_receipt_id,
375
+ paid_at=func.now(),
376
+ updated_at=func.now(),
377
+ )
378
+ )
379
+ if result.rowcount:
380
+ await self._mark_product_order_status(session, order_id, "paid")
381
+
382
+ async def _mark_order_fulfilled_unsettled_once(
383
+ self,
384
+ session: AsyncSession,
385
+ *,
386
+ order_id: str,
387
+ requirement_id: str,
388
+ pricing_band: str,
389
+ ) -> None:
390
+ result = await session.execute(
391
+ update(checkout_attempts)
392
+ .where(checkout_attempts.c.order_id == order_id)
393
+ .where(checkout_attempts.c.status.notin_(["fulfilled_unsettled", "paid", "expired", "cancelled", "failed"]))
394
+ .values(
395
+ status="fulfilled_unsettled",
396
+ active_key=None,
397
+ requirement_id=requirement_id,
398
+ pricing_band=pricing_band,
399
+ fulfilled_unsettled_at=func.now(),
400
+ updated_at=func.now(),
401
+ )
402
+ )
403
+ if result.rowcount:
404
+ await self._mark_product_order_status(session, order_id, "fulfilled_unsettled")
405
+
406
+ async def _flag_payment_review(self, session: AsyncSession, data: dict[str, Any]) -> None:
407
+ payload = json.dumps(data, separators=(",", ":"), sort_keys=True)
408
+ await session.execute(
409
+ insert(payment_reviews).values(
410
+ review_id=f"sdrp_review_{_sha256(f'{time.time()}:{payload}')[:24]}",
411
+ order_id=data.get("order_id") if isinstance(data.get("order_id"), str) else None,
412
+ reason=str(data.get("reason") or "manual_review_required"),
413
+ payload_json=payload,
414
+ )
415
+ )
416
+
417
+ def _canonical_order_dict(self, row: Any) -> dict[str, Any]:
418
+ return {
419
+ "id": row[self._order_id_column],
420
+ "amount_minor": row[self._amount_minor_column],
421
+ "currency": row[self._currency_column],
422
+ }
423
+
424
+ async def _mark_product_order_status(self, session: AsyncSession, order_id: str, status: str) -> None:
425
+ if self._order_status_column is None:
426
+ return
427
+ values: dict[Any, Any] = {self._order_status_column: status}
428
+ if self._order_updated_at_column is not None:
429
+ values[self._order_updated_at_column] = func.now()
430
+ await session.execute(
431
+ update(self._orders_table)
432
+ .where(self._order_id_column == order_id)
433
+ .values(values)
434
+ )
435
+
436
+
437
+ def _stable_attempt(order_id: str, attempt_number: int) -> tuple[str, str]:
438
+ digest = _sha256(f"{order_id}:{attempt_number}")[:32]
439
+ return f"sdrp_attempt_{digest}", f"sdrp-{digest}"
440
+
441
+
442
+ def _checkout_attempt_dict(order: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
443
+ return {
444
+ "id": str(order["id"]),
445
+ "order_id": str(order["id"]),
446
+ "amount_minor": int(order["amount_minor"]),
447
+ "currency": str(order["currency"]),
448
+ "attempt_number": int(state.get("attempt_number") or 1),
449
+ "attempt_id": str(state["attempt_id"]),
450
+ "stable_nonce": str(state["stable_nonce"]),
451
+ "status": str(state.get("status") or ""),
452
+ "checkout_session_id": state.get("checkout_session_id"),
453
+ "checkout_url": state.get("checkout_url"),
454
+ "expires_at": state.get("expires_at"),
455
+ }
456
+
457
+
458
+ def _is_reusable_checkout_attempt(state: dict[str, Any]) -> bool:
459
+ return (
460
+ state.get("status") == "pending"
461
+ and bool(state.get("checkout_session_id"))
462
+ and bool(state.get("checkout_url"))
463
+ and not _timestamp_has_passed(state.get("expires_at"))
464
+ )
465
+
466
+
467
+ def _timestamp_has_passed(value: Any) -> bool:
468
+ if value is None:
469
+ return False
470
+ parsed = _parse_datetime(value)
471
+ if parsed is None:
472
+ return False
473
+ return parsed <= _now_utc()
474
+
475
+
476
+ def _parse_datetime(value: Any) -> Any:
477
+ from .siglume_order_store_sqlalchemy import _parse_datetime as parse_datetime
478
+
479
+ return parse_datetime(value)
480
+
481
+
482
+ def _now_utc() -> Any:
483
+ from .siglume_order_store_sqlalchemy import _now_utc as now_utc
484
+
485
+ return now_utc()
486
+
487
+
488
+ def _sha256(value: str) -> str:
489
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
490
+
491
+
492
+ def _require_text(value: str, name: str) -> str:
493
+ text = str(value or "").strip()
494
+ if not text:
495
+ raise ValueError(f"{name} is required")
496
+ return text
@@ -25,6 +25,14 @@ class SiglumeSdrpOrderStore(Protocol):
25
25
  challenge_hash: str,
26
26
  checkout_session_id: str,
27
27
  checkout_url: str,
28
+ expires_at: str | None = None,
29
+ ) -> None: ...
30
+ async def mark_checkout_failed(
31
+ self,
32
+ *,
33
+ order_id: str,
34
+ attempt_id: str,
35
+ error_message: str | None = None,
28
36
  ) -> None: ...
29
37
  async def process_webhook_event_once(
30
38
  self,
@@ -60,6 +68,9 @@ def create_siglume_sdrp_router(
60
68
  if not allow_metered_payments and not _is_standard_checkout_amount(str(attempt["currency"]), int(attempt["amount_minor"])):
61
69
  return JSONResponse({"error": "METERED_INTEGRATION_REQUIRED"}, status_code=409)
62
70
 
71
+ if attempt.get("checkout_creation_pending"):
72
+ return JSONResponse({"status": "creating", "retry_after_ms": 250}, status_code=202)
73
+
63
74
  if attempt.get("checkout_url") and attempt.get("checkout_session_id"):
64
75
  return JSONResponse({
65
76
  "checkout_url": attempt["checkout_url"],
@@ -79,7 +90,19 @@ def create_siglume_sdrp_router(
79
90
  )
80
91
  )
81
92
  except HostedCheckoutNotAvailableError:
93
+ await order_store.mark_checkout_failed(
94
+ order_id=str(attempt["order_id"]),
95
+ attempt_id=str(attempt["attempt_id"]),
96
+ error_message="hosted_checkout_not_enabled",
97
+ )
82
98
  return JSONResponse({"error": "hosted_checkout_not_enabled"}, status_code=409)
99
+ except Exception as exc:
100
+ await order_store.mark_checkout_failed(
101
+ order_id=str(attempt["order_id"]),
102
+ attempt_id=str(attempt["attempt_id"]),
103
+ error_message=str(exc),
104
+ )
105
+ raise
83
106
 
84
107
  await order_store.mark_checkout_pending(
85
108
  order_id=str(attempt["order_id"]),
@@ -88,6 +111,7 @@ def create_siglume_sdrp_router(
88
111
  challenge_hash=session["challenge_hash"],
89
112
  checkout_session_id=session["session_id"],
90
113
  checkout_url=session["checkout_url"],
114
+ expires_at=session.get("expires_at"),
91
115
  )
92
116
  return JSONResponse({"checkout_url": session["checkout_url"], "session_id": session["session_id"]})
93
117
 
@@ -122,6 +146,8 @@ async def _process_siglume_webhook_event(
122
146
  ) -> None:
123
147
  if event["type"] != "direct_payment.confirmed":
124
148
  return
149
+ if _is_readiness_probe_event(event):
150
+ return
125
151
 
126
152
  confirmation = classify_direct_payment_confirmation(event)
127
153
  if confirmation["kind"] == "standard_settled":
@@ -168,3 +194,8 @@ def _is_standard_checkout_amount(currency: str, amount_minor: int) -> bool:
168
194
  if normalized_currency == "USD":
169
195
  return amount_minor >= 301
170
196
  return False
197
+
198
+
199
+ def _is_readiness_probe_event(event: dict[str, Any]) -> bool:
200
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
201
+ return data.get("readiness_probe") is True or data.get("mode") == "readiness_probe"