@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.
- package/CHANGELOG.md +36 -0
- package/README.md +23 -12
- package/bin/siglume-sdrp.mjs +302 -19
- 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 +29 -4
- package/docs/troubleshooting.md +12 -8
- package/examples/hosted-checkout-python/pyproject.toml +1 -1
- package/package.json +2 -2
- package/templates/express/siglume-order-store.sql.ts +279 -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
|
@@ -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"
|