@siglume/direct-request-payment 0.4.19 → 0.4.22
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 +50 -0
- package/README.md +18 -10
- package/bin/siglume-sdrp.mjs +550 -8
- package/dist/index.cjs +37 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/docs/announcement-ja.md +17 -3
- package/docs/api-reference.md +60 -13
- package/docs/merchant-quickstart.md +6 -20
- package/docs/metered-statements.md +15 -13
- package/docs/payment-lifecycle.md +12 -9
- package/docs/pricing.md +7 -4
- package/docs/quickstart-10-minutes.md +134 -24
- package/docs/sandbox.md +60 -0
- package/docs/troubleshooting.md +23 -8
- package/examples/express-checkout.ts +37 -13
- package/examples/hosted-checkout-python/app.py +46 -31
- package/examples/hosted-checkout-python/order_store.py +13 -3
- package/examples/hosted-checkout-python/pyproject.toml +1 -1
- package/examples/hosted-checkout-typescript/src/order-store.ts +14 -3
- package/examples/hosted-checkout-typescript/src/server.ts +49 -37
- package/package.json +10 -2
- package/templates/express/README.md +40 -6
- package/templates/express/siglume-order-store.example.ts +22 -6
- package/templates/express/siglume-order-store.sql.ts +585 -0
- package/templates/express/siglume-sdrp-routes.ts +138 -64
- package/templates/fastapi/README.md +22 -3
- package/templates/fastapi/siglume_order_store_example.py +29 -6
- package/templates/fastapi/siglume_order_store_sqlalchemy.py +313 -0
- package/templates/fastapi/siglume_sdrp_routes.py +112 -49
|
@@ -13,14 +13,28 @@ export interface SiglumeCheckoutOrder {
|
|
|
13
13
|
currency: DirectRequestPaymentCurrency | string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface SiglumeCheckoutAttempt extends SiglumeCheckoutOrder {
|
|
17
|
+
order_id: string;
|
|
18
|
+
attempt_id: string;
|
|
19
|
+
stable_nonce: string;
|
|
20
|
+
checkout_url?: string;
|
|
21
|
+
checkout_session_id?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export interface SiglumeSdrpOrderStore {
|
|
17
|
-
|
|
25
|
+
beginCheckoutAttempt(orderId: string, req: express.Request): Promise<SiglumeCheckoutAttempt | null>;
|
|
18
26
|
markCheckoutPending(input: {
|
|
19
27
|
order_id: string;
|
|
28
|
+
attempt_id: string;
|
|
29
|
+
stable_nonce: string;
|
|
20
30
|
challenge_hash: string;
|
|
21
31
|
checkout_session_id: string;
|
|
32
|
+
checkout_url: string;
|
|
22
33
|
}): Promise<void>;
|
|
23
|
-
|
|
34
|
+
processWebhookEventOnce(
|
|
35
|
+
eventId: string,
|
|
36
|
+
handler: () => Promise<void>,
|
|
37
|
+
): Promise<"processed" | "duplicate">;
|
|
24
38
|
findOrderByChallengeHash(challengeHash: string): Promise<{ id: string } | null>;
|
|
25
39
|
markOrderPaidOnce(input: {
|
|
26
40
|
order_id: string;
|
|
@@ -41,9 +55,10 @@ export interface SiglumeSdrpRouterOptions {
|
|
|
41
55
|
webhook_secret: string;
|
|
42
56
|
shop_public_origin: string;
|
|
43
57
|
order_store: SiglumeSdrpOrderStore;
|
|
58
|
+
allow_metered_payments?: boolean;
|
|
44
59
|
}
|
|
45
60
|
|
|
46
|
-
export function
|
|
61
|
+
export function createSiglumeSdrpCheckoutRouter(options: SiglumeSdrpRouterOptions): express.Router {
|
|
47
62
|
const router = express.Router();
|
|
48
63
|
const merchant = new DirectRequestPaymentMerchantClient({
|
|
49
64
|
auth_token: options.merchant_auth_token,
|
|
@@ -52,26 +67,39 @@ export function createSiglumeSdrpRouter(options: SiglumeSdrpRouterOptions): expr
|
|
|
52
67
|
router.post("/checkout/siglume/start", express.json(), async (req, res, next) => {
|
|
53
68
|
try {
|
|
54
69
|
const orderId = String(req.body?.order_id || "");
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
70
|
+
const attempt = await options.order_store.beginCheckoutAttempt(orderId, req);
|
|
71
|
+
if (!attempt) {
|
|
57
72
|
res.status(404).json({ error: "order_not_found" });
|
|
58
73
|
return;
|
|
59
74
|
}
|
|
60
75
|
|
|
76
|
+
if (!options.allow_metered_payments && !isStandardCheckoutAmount(attempt.currency, attempt.amount_minor)) {
|
|
77
|
+
res.status(409).json({ error: "METERED_INTEGRATION_REQUIRED" });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (attempt.checkout_url && attempt.checkout_session_id) {
|
|
82
|
+
res.json({ checkout_url: attempt.checkout_url, session_id: attempt.checkout_session_id });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
const session = await merchant.createCheckoutSession({
|
|
62
87
|
merchant: options.merchant,
|
|
63
|
-
amount_minor:
|
|
64
|
-
currency:
|
|
65
|
-
nonce:
|
|
88
|
+
amount_minor: attempt.amount_minor,
|
|
89
|
+
currency: attempt.currency,
|
|
90
|
+
nonce: attempt.stable_nonce,
|
|
66
91
|
success_url: `${options.shop_public_origin}/checkout/siglume/success`,
|
|
67
92
|
cancel_url: `${options.shop_public_origin}/checkout/siglume/cancel`,
|
|
68
|
-
metadata: { order_id:
|
|
93
|
+
metadata: { order_id: attempt.order_id, attempt_id: attempt.attempt_id },
|
|
69
94
|
});
|
|
70
95
|
|
|
71
96
|
await options.order_store.markCheckoutPending({
|
|
72
|
-
order_id:
|
|
97
|
+
order_id: attempt.order_id,
|
|
98
|
+
attempt_id: attempt.attempt_id,
|
|
99
|
+
stable_nonce: attempt.stable_nonce,
|
|
73
100
|
challenge_hash: session.challenge_hash,
|
|
74
101
|
checkout_session_id: session.session_id,
|
|
102
|
+
checkout_url: session.checkout_url,
|
|
75
103
|
});
|
|
76
104
|
|
|
77
105
|
res.json({ checkout_url: session.checkout_url, session_id: session.session_id });
|
|
@@ -80,7 +108,19 @@ export function createSiglumeSdrpRouter(options: SiglumeSdrpRouterOptions): expr
|
|
|
80
108
|
}
|
|
81
109
|
});
|
|
82
110
|
|
|
83
|
-
router.
|
|
111
|
+
router.use((error: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
112
|
+
if (error instanceof HostedCheckoutNotAvailableError) {
|
|
113
|
+
res.status(409).json({ error: "hosted_checkout_not_enabled" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
next(error);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return router;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createSiglumeSdrpWebhookHandler(options: SiglumeSdrpRouterOptions): express.RequestHandler {
|
|
123
|
+
return async (req, res, next) => {
|
|
84
124
|
try {
|
|
85
125
|
const { event } = await verifyDirectRequestPaymentWebhook(
|
|
86
126
|
options.webhook_secret,
|
|
@@ -88,70 +128,104 @@ export function createSiglumeSdrpRouter(options: SiglumeSdrpRouterOptions): expr
|
|
|
88
128
|
req.header("siglume-signature") || "",
|
|
89
129
|
);
|
|
90
130
|
|
|
91
|
-
|
|
131
|
+
const result = await options.order_store.processWebhookEventOnce(event.id, async () => {
|
|
132
|
+
await processSiglumeWebhookEvent(options, event);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (result === "duplicate") {
|
|
92
136
|
res.status(204).send();
|
|
93
137
|
return;
|
|
94
138
|
}
|
|
95
139
|
|
|
96
|
-
if (event.type === "direct_payment.confirmed") {
|
|
97
|
-
const confirmation = classifyDirectPaymentConfirmation(event);
|
|
98
|
-
|
|
99
|
-
if (confirmation.kind === "standard_settled") {
|
|
100
|
-
const order = await options.order_store.findOrderByChallengeHash(confirmation.challenge_hash);
|
|
101
|
-
if (order) {
|
|
102
|
-
await options.order_store.markOrderPaidOnce({
|
|
103
|
-
order_id: order.id,
|
|
104
|
-
requirement_id: confirmation.requirement_id,
|
|
105
|
-
chain_receipt_id: confirmation.chain_receipt_id,
|
|
106
|
-
});
|
|
107
|
-
} else {
|
|
108
|
-
await options.order_store.flagPaymentReview({
|
|
109
|
-
reason: "unknown_challenge_hash",
|
|
110
|
-
requirement_id: confirmation.requirement_id,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
} else if (confirmation.kind === "metered_usage_accepted") {
|
|
114
|
-
const order = await options.order_store.findOrderByChallengeHash(confirmation.challenge_hash);
|
|
115
|
-
if (order) {
|
|
116
|
-
await options.order_store.markOrderFulfilledUnsettledOnce({
|
|
117
|
-
order_id: order.id,
|
|
118
|
-
requirement_id: confirmation.requirement_id,
|
|
119
|
-
pricing_band: confirmation.pricing_band,
|
|
120
|
-
});
|
|
121
|
-
} else {
|
|
122
|
-
await options.order_store.flagPaymentReview({
|
|
123
|
-
reason: "unknown_metered_challenge_hash",
|
|
124
|
-
requirement_id: confirmation.requirement_id,
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
} else if (confirmation.kind === "metered_batch_settled") {
|
|
128
|
-
await options.order_store.flagPaymentReview({
|
|
129
|
-
reason: "metered_batch_settled_reconcile_statement_api",
|
|
130
|
-
settlement_batch_id: confirmation.settlement_batch_id,
|
|
131
|
-
chain_receipt_id: confirmation.chain_receipt_id,
|
|
132
|
-
});
|
|
133
|
-
} else {
|
|
134
|
-
await options.order_store.flagPaymentReview({
|
|
135
|
-
reason: confirmation.reason,
|
|
136
|
-
requirement_id: confirmation.requirement_id,
|
|
137
|
-
settlement_batch_id: confirmation.settlement_batch_id,
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
140
|
res.status(204).send();
|
|
143
141
|
} catch (error) {
|
|
144
142
|
next(error);
|
|
145
143
|
}
|
|
146
|
-
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
147
146
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
export function createSiglumeSdrpRouter(options: SiglumeSdrpRouterOptions): express.Router {
|
|
148
|
+
const router = createSiglumeSdrpCheckoutRouter(options);
|
|
149
|
+
router.post(
|
|
150
|
+
"/webhooks/siglume",
|
|
151
|
+
express.raw({ type: "application/json" }),
|
|
152
|
+
createSiglumeSdrpWebhookHandler(options),
|
|
153
|
+
);
|
|
154
|
+
return router;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function processSiglumeWebhookEvent(
|
|
158
|
+
options: SiglumeSdrpRouterOptions,
|
|
159
|
+
event: Awaited<ReturnType<typeof verifyDirectRequestPaymentWebhook>>["event"],
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
if (event.type !== "direct_payment.confirmed") {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const confirmation = classifyDirectPaymentConfirmation(event);
|
|
166
|
+
|
|
167
|
+
if (confirmation.kind === "standard_settled") {
|
|
168
|
+
const order = await options.order_store.findOrderByChallengeHash(confirmation.challenge_hash);
|
|
169
|
+
if (order) {
|
|
170
|
+
await options.order_store.markOrderPaidOnce({
|
|
171
|
+
order_id: order.id,
|
|
172
|
+
requirement_id: confirmation.requirement_id,
|
|
173
|
+
chain_receipt_id: confirmation.chain_receipt_id,
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
await options.order_store.flagPaymentReview({
|
|
177
|
+
reason: "unknown_challenge_hash",
|
|
178
|
+
requirement_id: confirmation.requirement_id,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (confirmation.kind === "metered_usage_accepted") {
|
|
185
|
+
if (!options.allow_metered_payments) {
|
|
186
|
+
await options.order_store.flagPaymentReview({
|
|
187
|
+
reason: "metered_integration_required",
|
|
188
|
+
requirement_id: confirmation.requirement_id,
|
|
189
|
+
pricing_band: confirmation.pricing_band,
|
|
190
|
+
});
|
|
151
191
|
return;
|
|
152
192
|
}
|
|
153
|
-
|
|
193
|
+
const order = await options.order_store.findOrderByChallengeHash(confirmation.challenge_hash);
|
|
194
|
+
if (order) {
|
|
195
|
+
await options.order_store.markOrderFulfilledUnsettledOnce({
|
|
196
|
+
order_id: order.id,
|
|
197
|
+
requirement_id: confirmation.requirement_id,
|
|
198
|
+
pricing_band: confirmation.pricing_band,
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
await options.order_store.flagPaymentReview({
|
|
202
|
+
reason: "unknown_metered_challenge_hash",
|
|
203
|
+
requirement_id: confirmation.requirement_id,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (confirmation.kind === "metered_batch_settled") {
|
|
210
|
+
await options.order_store.flagPaymentReview({
|
|
211
|
+
reason: "metered_batch_settled_reconcile_statement_api",
|
|
212
|
+
settlement_batch_id: confirmation.settlement_batch_id,
|
|
213
|
+
chain_receipt_id: confirmation.chain_receipt_id,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await options.order_store.flagPaymentReview({
|
|
219
|
+
reason: confirmation.reason,
|
|
220
|
+
requirement_id: confirmation.requirement_id,
|
|
221
|
+
settlement_batch_id: confirmation.settlement_batch_id,
|
|
154
222
|
});
|
|
223
|
+
}
|
|
155
224
|
|
|
156
|
-
|
|
225
|
+
function isStandardCheckoutAmount(currency: string, amountMinor: number): boolean {
|
|
226
|
+
if (!Number.isSafeInteger(amountMinor)) return false;
|
|
227
|
+
const normalizedCurrency = String(currency || "").toUpperCase();
|
|
228
|
+
if (normalizedCurrency === "JPY") return amountMinor >= 501;
|
|
229
|
+
if (normalizedCurrency === "USD") return amountMinor >= 301;
|
|
230
|
+
return false;
|
|
157
231
|
}
|
|
@@ -5,17 +5,36 @@ Mount the router in your existing app:
|
|
|
5
5
|
```py
|
|
6
6
|
from fastapi import FastAPI
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import os
|
|
9
|
+
from sqlalchemy.orm import sessionmaker
|
|
10
|
+
|
|
11
|
+
from .siglume.siglume_order_store_sqlalchemy import (
|
|
12
|
+
SQLAlchemySiglumeOrderStore,
|
|
13
|
+
create_sqlalchemy_engine,
|
|
14
|
+
create_sqlalchemy_siglume_schema,
|
|
15
|
+
)
|
|
9
16
|
from .siglume.siglume_sdrp_routes import create_siglume_sdrp_router
|
|
10
17
|
|
|
11
18
|
app = FastAPI()
|
|
19
|
+
engine = create_sqlalchemy_engine(os.environ["DATABASE_URL"])
|
|
20
|
+
create_sqlalchemy_siglume_schema(engine)
|
|
21
|
+
SessionLocal = sessionmaker(engine, future=True)
|
|
22
|
+
siglume_order_store = SQLAlchemySiglumeOrderStore(SessionLocal)
|
|
23
|
+
|
|
12
24
|
app.include_router(
|
|
13
|
-
create_siglume_sdrp_router(
|
|
25
|
+
create_siglume_sdrp_router(siglume_order_store, allow_metered_payments=False),
|
|
14
26
|
prefix="/payments",
|
|
15
27
|
)
|
|
16
28
|
```
|
|
17
29
|
|
|
18
|
-
|
|
30
|
+
Use `siglume_order_store_sqlalchemy.py` for a durable SQLAlchemy adapter. It
|
|
31
|
+
creates the required checkout attempt, webhook event, and payment review tables
|
|
32
|
+
and keeps webhook processing transactional.
|
|
33
|
+
|
|
34
|
+
Keep `process_webhook_event_once()` transactional: record the webhook event as
|
|
35
|
+
processed only after the order update or review write succeeds. The generated
|
|
36
|
+
route defaults to Standard-only. Enable `allow_metered_payments` only after you
|
|
37
|
+
implement Micro / Nano settlement reconciliation and past-due handling.
|
|
19
38
|
The route paths become:
|
|
20
39
|
|
|
21
40
|
- `POST /payments/checkout/siglume/start`
|
|
@@ -11,22 +11,45 @@ _processed_events: set[str] = set()
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class ExampleSiglumeOrderStore:
|
|
14
|
-
async def
|
|
15
|
-
|
|
14
|
+
async def begin_checkout_attempt(self, order_id: str, request: Request) -> dict[str, Any] | None:
|
|
15
|
+
order = _orders.get(order_id)
|
|
16
|
+
if order is None:
|
|
17
|
+
return None
|
|
18
|
+
order.setdefault("attempt_id", f"{order['id']}_attempt_1")
|
|
19
|
+
order.setdefault("stable_nonce", f"{order['id']}-attempt_1")
|
|
20
|
+
return {
|
|
21
|
+
**order,
|
|
22
|
+
"order_id": order["id"],
|
|
23
|
+
"attempt_id": order["attempt_id"],
|
|
24
|
+
"stable_nonce": order["stable_nonce"],
|
|
25
|
+
}
|
|
16
26
|
|
|
17
|
-
async def mark_checkout_pending(
|
|
27
|
+
async def mark_checkout_pending(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
order_id: str,
|
|
31
|
+
attempt_id: str,
|
|
32
|
+
stable_nonce: str,
|
|
33
|
+
challenge_hash: str,
|
|
34
|
+
checkout_session_id: str,
|
|
35
|
+
checkout_url: str,
|
|
36
|
+
) -> None:
|
|
18
37
|
order = _orders.get(order_id)
|
|
19
38
|
if not order:
|
|
20
39
|
return
|
|
21
40
|
order["status"] = "pending"
|
|
41
|
+
order["attempt_id"] = attempt_id
|
|
42
|
+
order["stable_nonce"] = stable_nonce
|
|
22
43
|
order["challenge_hash"] = challenge_hash
|
|
23
44
|
order["checkout_session_id"] = checkout_session_id
|
|
45
|
+
order["checkout_url"] = checkout_url
|
|
24
46
|
|
|
25
|
-
async def
|
|
47
|
+
async def process_webhook_event_once(self, event_id: str, handler) -> str:
|
|
26
48
|
if event_id in _processed_events:
|
|
27
|
-
return
|
|
49
|
+
return "duplicate"
|
|
50
|
+
await handler()
|
|
28
51
|
_processed_events.add(event_id)
|
|
29
|
-
return
|
|
52
|
+
return "processed"
|
|
30
53
|
|
|
31
54
|
async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None:
|
|
32
55
|
for order in _orders.values():
|
|
@@ -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
|