@siglume/direct-request-payment 0.4.20 → 0.4.23

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,313 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ import hashlib
5
+ import json
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Any, Literal
9
+
10
+ from fastapi import Request
11
+ from sqlalchemy import (
12
+ BigInteger,
13
+ Column,
14
+ DateTime,
15
+ MetaData,
16
+ String,
17
+ Table,
18
+ Text,
19
+ create_engine,
20
+ insert,
21
+ select,
22
+ update,
23
+ )
24
+ from sqlalchemy.engine import Engine
25
+ from sqlalchemy.exc import IntegrityError
26
+ from sqlalchemy.orm import Session, sessionmaker
27
+ from sqlalchemy.sql import func
28
+
29
+
30
+ metadata = MetaData()
31
+
32
+ orders = Table(
33
+ "orders",
34
+ metadata,
35
+ Column("id", String(255), primary_key=True),
36
+ Column("amount_minor", BigInteger, nullable=False),
37
+ Column("currency", String(8), nullable=False),
38
+ Column("status", String(32), nullable=False, default="created"),
39
+ Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
40
+ Column("updated_at", DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False),
41
+ )
42
+
43
+ checkout_attempts = Table(
44
+ "siglume_checkout_attempts",
45
+ metadata,
46
+ Column("order_id", String(255), primary_key=True),
47
+ Column("attempt_id", String(255), nullable=False, unique=True),
48
+ Column("stable_nonce", String(255), nullable=False, unique=True),
49
+ Column("status", String(32), nullable=False, default="created"),
50
+ Column("challenge_hash", String(255), unique=True),
51
+ Column("checkout_session_id", String(255)),
52
+ Column("checkout_url", Text),
53
+ Column("requirement_id", String(255)),
54
+ Column("chain_receipt_id", String(255)),
55
+ Column("pricing_band", String(32)),
56
+ Column("paid_at", DateTime(timezone=True)),
57
+ Column("fulfilled_unsettled_at", DateTime(timezone=True)),
58
+ Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
59
+ Column("updated_at", DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False),
60
+ )
61
+
62
+ webhook_events = Table(
63
+ "siglume_webhook_events",
64
+ metadata,
65
+ Column("event_id", String(255), primary_key=True),
66
+ Column("status", String(32), nullable=False),
67
+ Column("error_message", Text),
68
+ Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
69
+ Column("processed_at", DateTime(timezone=True)),
70
+ )
71
+
72
+ payment_reviews = Table(
73
+ "siglume_payment_reviews",
74
+ metadata,
75
+ Column("review_id", String(255), primary_key=True),
76
+ Column("order_id", String(255)),
77
+ Column("reason", String(128), nullable=False),
78
+ Column("payload_json", Text, nullable=False),
79
+ Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
80
+ )
81
+
82
+ _current_session: contextvars.ContextVar[Session | None] = contextvars.ContextVar(
83
+ "siglume_sdrp_sqlalchemy_session",
84
+ default=None,
85
+ )
86
+
87
+
88
+ def create_sqlalchemy_engine(database_url: str) -> Engine:
89
+ connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
90
+ return create_engine(database_url, future=True, connect_args=connect_args)
91
+
92
+
93
+ def create_sqlalchemy_siglume_schema(engine: Engine) -> None:
94
+ metadata.create_all(engine)
95
+
96
+
97
+ def seed_sqlalchemy_order(
98
+ session: Session,
99
+ *,
100
+ order_id: str,
101
+ amount_minor: int,
102
+ currency: str,
103
+ status: str = "created",
104
+ ) -> None:
105
+ session.execute(
106
+ insert(orders).values(
107
+ id=order_id,
108
+ amount_minor=amount_minor,
109
+ currency=currency.upper(),
110
+ status=status,
111
+ )
112
+ )
113
+
114
+
115
+ class SQLAlchemySiglumeOrderStore:
116
+ def __init__(
117
+ self,
118
+ session_factory: sessionmaker[Session],
119
+ *,
120
+ authorize_order: Callable[[dict[str, Any], Request], bool] | None = None,
121
+ ) -> None:
122
+ self._session_factory = session_factory
123
+ self._authorize_order = authorize_order
124
+
125
+ async def begin_checkout_attempt(self, order_id: str, request: Request) -> dict[str, Any] | None:
126
+ 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)
150
+ ).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
+ }
162
+
163
+ async def mark_checkout_pending(
164
+ self,
165
+ *,
166
+ order_id: str,
167
+ attempt_id: str,
168
+ stable_nonce: str,
169
+ challenge_hash: str,
170
+ checkout_session_id: str,
171
+ checkout_url: str,
172
+ ) -> None:
173
+ session = self._session()
174
+ session.execute(
175
+ update(checkout_attempts)
176
+ .where(checkout_attempts.c.order_id == order_id)
177
+ .values(
178
+ status="pending",
179
+ attempt_id=attempt_id,
180
+ stable_nonce=stable_nonce,
181
+ challenge_hash=challenge_hash,
182
+ checkout_session_id=checkout_session_id,
183
+ checkout_url=checkout_url,
184
+ updated_at=func.now(),
185
+ )
186
+ )
187
+ self._commit_if_own_session(session)
188
+
189
+ async def process_webhook_event_once(
190
+ self,
191
+ event_id: str,
192
+ handler: Callable[[], Awaitable[None]],
193
+ ) -> Literal["processed", "duplicate"]:
194
+ clean_event_id = _require_text(event_id, "event_id")
195
+ with self._session_factory.begin() as session:
196
+ existing = session.execute(
197
+ select(webhook_events.c.event_id).where(webhook_events.c.event_id == clean_event_id)
198
+ ).first()
199
+ if existing is not None:
200
+ return "duplicate"
201
+ try:
202
+ session.execute(insert(webhook_events).values(event_id=clean_event_id, status="processing"))
203
+ except IntegrityError:
204
+ return "duplicate"
205
+ token = _current_session.set(session)
206
+ try:
207
+ await handler()
208
+ session.execute(
209
+ update(webhook_events)
210
+ .where(webhook_events.c.event_id == clean_event_id)
211
+ .values(status="processed", processed_at=func.now())
212
+ )
213
+ return "processed"
214
+ finally:
215
+ _current_session.reset(token)
216
+
217
+ async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None:
218
+ session = self._session()
219
+ row = session.execute(
220
+ select(checkout_attempts.c.order_id).where(checkout_attempts.c.challenge_hash == challenge_hash)
221
+ ).first()
222
+ self._close_if_own_session(session)
223
+ return {"id": row[0]} if row else None
224
+
225
+ async def mark_order_paid_once(self, *, order_id: str, requirement_id: str, chain_receipt_id: str) -> None:
226
+ session = self._session()
227
+ result = session.execute(
228
+ update(checkout_attempts)
229
+ .where(checkout_attempts.c.order_id == order_id)
230
+ .where(checkout_attempts.c.status != "paid")
231
+ .values(
232
+ status="paid",
233
+ requirement_id=requirement_id,
234
+ chain_receipt_id=chain_receipt_id,
235
+ paid_at=func.now(),
236
+ updated_at=func.now(),
237
+ )
238
+ )
239
+ 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
+ )
245
+ self._commit_if_own_session(session)
246
+
247
+ async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None:
248
+ session = self._session()
249
+ result = session.execute(
250
+ update(checkout_attempts)
251
+ .where(checkout_attempts.c.order_id == order_id)
252
+ .where(checkout_attempts.c.status.notin_(["fulfilled_unsettled", "paid"]))
253
+ .values(
254
+ status="fulfilled_unsettled",
255
+ requirement_id=requirement_id,
256
+ pricing_band=pricing_band,
257
+ fulfilled_unsettled_at=func.now(),
258
+ updated_at=func.now(),
259
+ )
260
+ )
261
+ 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
+ )
267
+ self._commit_if_own_session(session)
268
+
269
+ async def flag_payment_review(self, data: dict[str, Any]) -> None:
270
+ session = self._session()
271
+ payload = json.dumps(data, separators=(",", ":"), sort_keys=True)
272
+ session.execute(
273
+ insert(payment_reviews).values(
274
+ review_id=f"sdrp_review_{_sha256(f'{time.time()}:{payload}')[:24]}",
275
+ order_id=data.get("order_id") if isinstance(data.get("order_id"), str) else None,
276
+ reason=str(data.get("reason") or "manual_review_required"),
277
+ payload_json=payload,
278
+ )
279
+ )
280
+ self._commit_if_own_session(session)
281
+
282
+ def _session(self) -> Session:
283
+ current = _current_session.get()
284
+ if current is not None:
285
+ return current
286
+ return self._session_factory()
287
+
288
+ def _commit_if_own_session(self, session: Session) -> None:
289
+ if _current_session.get() is not session:
290
+ try:
291
+ session.commit()
292
+ finally:
293
+ session.close()
294
+
295
+ def _close_if_own_session(self, session: Session) -> None:
296
+ if _current_session.get() is not session:
297
+ session.close()
298
+
299
+
300
+ def _stable_attempt(order_id: str) -> tuple[str, str]:
301
+ digest = _sha256(order_id)[:32]
302
+ return f"sdrp_attempt_{digest}", f"sdrp-{digest}"
303
+
304
+
305
+ def _sha256(value: str) -> str:
306
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
307
+
308
+
309
+ def _require_text(value: str, name: str) -> str:
310
+ text = str(value or "").strip()
311
+ if not text:
312
+ raise ValueError(f"{name} is required")
313
+ return text