@siglume/direct-request-payment 0.4.22 → 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.
@@ -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("order_id", String(255), primary_key=True),
47
- Column("attempt_id", String(255), nullable=False, unique=True),
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
- metadata.create_all(engine)
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
- with self._session_factory.begin() as session:
128
- order = session.execute(select(orders).where(orders.c.id == clean_order_id)).mappings().first()
129
- if order is None:
130
- return None
131
- order_dict = dict(order)
132
- if self._authorize_order and not self._authorize_order(order_dict, request):
133
- return None
134
-
135
- attempt_id, stable_nonce = _stable_attempt(clean_order_id)
136
- existing = session.execute(
137
- select(checkout_attempts).where(checkout_attempts.c.order_id == clean_order_id)
138
- ).mappings().first()
139
- if existing is None:
140
- session.execute(
141
- insert(checkout_attempts).values(
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
- state = dict(existing or {})
152
- return {
153
- "id": clean_order_id,
154
- "order_id": clean_order_id,
155
- "amount_minor": int(order_dict["amount_minor"]),
156
- "currency": str(order_dict["currency"]),
157
- "attempt_id": str(state.get("attempt_id") or attempt_id),
158
- "stable_nonce": str(state.get("stable_nonce") or stable_nonce),
159
- "checkout_session_id": state.get("checkout_session_id"),
160
- "checkout_url": state.get("checkout_url"),
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 != "paid")
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
- session.execute(
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
- session.execute(
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
- def _stable_attempt(order_id: str) -> tuple[str, str]:
301
- digest = _sha256(order_id)[:32]
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