@siglume/direct-request-payment 0.4.18 → 0.4.20
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 +44 -0
- package/README.md +32 -15
- package/bin/siglume-sdrp.mjs +398 -0
- package/dist/index.cjs +25 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -2
- package/dist/index.d.ts +26 -2
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/docs/announcement-ja.md +17 -3
- package/docs/api-reference.md +57 -13
- package/docs/merchant-quickstart.md +11 -26
- 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 +126 -128
- package/docs/troubleshooting.md +20 -5
- 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 +9 -1
- package/templates/express/README.md +42 -0
- package/templates/express/siglume-order-store.example.ts +69 -0
- package/templates/express/siglume-sdrp-routes.ts +231 -0
- package/templates/fastapi/README.md +26 -0
- package/templates/fastapi/siglume_order_store_example.py +77 -0
- package/templates/fastapi/siglume_sdrp_routes.py +170 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import {
|
|
3
|
+
classifyDirectPaymentConfirmation,
|
|
4
|
+
DirectRequestPaymentMerchantClient,
|
|
5
|
+
HostedCheckoutNotAvailableError,
|
|
6
|
+
verifyDirectRequestPaymentWebhook,
|
|
7
|
+
type DirectRequestPaymentCurrency,
|
|
8
|
+
} from "@siglume/direct-request-payment";
|
|
9
|
+
|
|
10
|
+
export interface SiglumeCheckoutOrder {
|
|
11
|
+
id: string;
|
|
12
|
+
amount_minor: number;
|
|
13
|
+
currency: DirectRequestPaymentCurrency | string;
|
|
14
|
+
}
|
|
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
|
+
|
|
24
|
+
export interface SiglumeSdrpOrderStore {
|
|
25
|
+
beginCheckoutAttempt(orderId: string, req: express.Request): Promise<SiglumeCheckoutAttempt | null>;
|
|
26
|
+
markCheckoutPending(input: {
|
|
27
|
+
order_id: string;
|
|
28
|
+
attempt_id: string;
|
|
29
|
+
stable_nonce: string;
|
|
30
|
+
challenge_hash: string;
|
|
31
|
+
checkout_session_id: string;
|
|
32
|
+
checkout_url: string;
|
|
33
|
+
}): Promise<void>;
|
|
34
|
+
processWebhookEventOnce(
|
|
35
|
+
eventId: string,
|
|
36
|
+
handler: () => Promise<void>,
|
|
37
|
+
): Promise<"processed" | "duplicate">;
|
|
38
|
+
findOrderByChallengeHash(challengeHash: string): Promise<{ id: string } | null>;
|
|
39
|
+
markOrderPaidOnce(input: {
|
|
40
|
+
order_id: string;
|
|
41
|
+
requirement_id: string;
|
|
42
|
+
chain_receipt_id: string;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
markOrderFulfilledUnsettledOnce(input: {
|
|
45
|
+
order_id: string;
|
|
46
|
+
requirement_id: string;
|
|
47
|
+
pricing_band: string;
|
|
48
|
+
}): Promise<void>;
|
|
49
|
+
flagPaymentReview(input: Record<string, unknown>): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SiglumeSdrpRouterOptions {
|
|
53
|
+
merchant: string;
|
|
54
|
+
merchant_auth_token: string;
|
|
55
|
+
webhook_secret: string;
|
|
56
|
+
shop_public_origin: string;
|
|
57
|
+
order_store: SiglumeSdrpOrderStore;
|
|
58
|
+
allow_metered_payments?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createSiglumeSdrpCheckoutRouter(options: SiglumeSdrpRouterOptions): express.Router {
|
|
62
|
+
const router = express.Router();
|
|
63
|
+
const merchant = new DirectRequestPaymentMerchantClient({
|
|
64
|
+
auth_token: options.merchant_auth_token,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
router.post("/checkout/siglume/start", express.json(), async (req, res, next) => {
|
|
68
|
+
try {
|
|
69
|
+
const orderId = String(req.body?.order_id || "");
|
|
70
|
+
const attempt = await options.order_store.beginCheckoutAttempt(orderId, req);
|
|
71
|
+
if (!attempt) {
|
|
72
|
+
res.status(404).json({ error: "order_not_found" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
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
|
+
|
|
86
|
+
const session = await merchant.createCheckoutSession({
|
|
87
|
+
merchant: options.merchant,
|
|
88
|
+
amount_minor: attempt.amount_minor,
|
|
89
|
+
currency: attempt.currency,
|
|
90
|
+
nonce: attempt.stable_nonce,
|
|
91
|
+
success_url: `${options.shop_public_origin}/checkout/siglume/success`,
|
|
92
|
+
cancel_url: `${options.shop_public_origin}/checkout/siglume/cancel`,
|
|
93
|
+
metadata: { order_id: attempt.order_id, attempt_id: attempt.attempt_id },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await options.order_store.markCheckoutPending({
|
|
97
|
+
order_id: attempt.order_id,
|
|
98
|
+
attempt_id: attempt.attempt_id,
|
|
99
|
+
stable_nonce: attempt.stable_nonce,
|
|
100
|
+
challenge_hash: session.challenge_hash,
|
|
101
|
+
checkout_session_id: session.session_id,
|
|
102
|
+
checkout_url: session.checkout_url,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
res.json({ checkout_url: session.checkout_url, session_id: session.session_id });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
next(error);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
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) => {
|
|
124
|
+
try {
|
|
125
|
+
const { event } = await verifyDirectRequestPaymentWebhook(
|
|
126
|
+
options.webhook_secret,
|
|
127
|
+
req.body,
|
|
128
|
+
req.header("siglume-signature") || "",
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const result = await options.order_store.processWebhookEventOnce(event.id, async () => {
|
|
132
|
+
await processSiglumeWebhookEvent(options, event);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (result === "duplicate") {
|
|
136
|
+
res.status(204).send();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
res.status(204).send();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
next(error);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
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
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
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,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
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;
|
|
231
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# FastAPI Integration Files
|
|
2
|
+
|
|
3
|
+
Mount the router in your existing app:
|
|
4
|
+
|
|
5
|
+
```py
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
8
|
+
from .siglume.siglume_order_store_example import ExampleSiglumeOrderStore
|
|
9
|
+
from .siglume.siglume_sdrp_routes import create_siglume_sdrp_router
|
|
10
|
+
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
app.include_router(
|
|
13
|
+
create_siglume_sdrp_router(ExampleSiglumeOrderStore(), allow_metered_payments=False),
|
|
14
|
+
prefix="/payments",
|
|
15
|
+
)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Replace `siglume_order_store_example.py` with your real order database adapter.
|
|
19
|
+
Keep `process_webhook_event_once()` transactional: record the webhook event as
|
|
20
|
+
processed only after the order update or review write succeeds. The generated
|
|
21
|
+
route defaults to Standard-only. Enable `allow_metered_payments` only after you
|
|
22
|
+
implement Micro / Nano settlement reconciliation and past-due handling.
|
|
23
|
+
The route paths become:
|
|
24
|
+
|
|
25
|
+
- `POST /payments/checkout/siglume/start`
|
|
26
|
+
- `POST /payments/webhooks/siglume`
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
|
|
7
|
+
_orders: dict[str, dict[str, Any]] = {
|
|
8
|
+
"order_123": {"id": "order_123", "amount_minor": 1200, "currency": "JPY", "status": "created"}
|
|
9
|
+
}
|
|
10
|
+
_processed_events: set[str] = set()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExampleSiglumeOrderStore:
|
|
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
|
+
}
|
|
26
|
+
|
|
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:
|
|
37
|
+
order = _orders.get(order_id)
|
|
38
|
+
if not order:
|
|
39
|
+
return
|
|
40
|
+
order["status"] = "pending"
|
|
41
|
+
order["attempt_id"] = attempt_id
|
|
42
|
+
order["stable_nonce"] = stable_nonce
|
|
43
|
+
order["challenge_hash"] = challenge_hash
|
|
44
|
+
order["checkout_session_id"] = checkout_session_id
|
|
45
|
+
order["checkout_url"] = checkout_url
|
|
46
|
+
|
|
47
|
+
async def process_webhook_event_once(self, event_id: str, handler) -> str:
|
|
48
|
+
if event_id in _processed_events:
|
|
49
|
+
return "duplicate"
|
|
50
|
+
await handler()
|
|
51
|
+
_processed_events.add(event_id)
|
|
52
|
+
return "processed"
|
|
53
|
+
|
|
54
|
+
async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None:
|
|
55
|
+
for order in _orders.values():
|
|
56
|
+
if order.get("challenge_hash") == challenge_hash:
|
|
57
|
+
return order
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
async def mark_order_paid_once(self, *, order_id: str, requirement_id: str, chain_receipt_id: str) -> None:
|
|
61
|
+
order = _orders.get(order_id)
|
|
62
|
+
if not order or order.get("status") == "paid":
|
|
63
|
+
return
|
|
64
|
+
order["status"] = "paid"
|
|
65
|
+
order["requirement_id"] = requirement_id
|
|
66
|
+
order["chain_receipt_id"] = chain_receipt_id
|
|
67
|
+
|
|
68
|
+
async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None:
|
|
69
|
+
order = _orders.get(order_id)
|
|
70
|
+
if not order or order.get("status") == "fulfilled_unsettled":
|
|
71
|
+
return
|
|
72
|
+
order["status"] = "fulfilled_unsettled"
|
|
73
|
+
order["requirement_id"] = requirement_id
|
|
74
|
+
order["pricing_band"] = pricing_band
|
|
75
|
+
|
|
76
|
+
async def flag_payment_review(self, data: dict[str, Any]) -> None:
|
|
77
|
+
print("payment review required", data)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Awaitable, Callable, Literal, Protocol
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Request
|
|
7
|
+
from fastapi.responses import JSONResponse, Response
|
|
8
|
+
from starlette.concurrency import run_in_threadpool
|
|
9
|
+
from siglume_direct_request_payment import (
|
|
10
|
+
DirectRequestPaymentMerchantClient,
|
|
11
|
+
HostedCheckoutNotAvailableError,
|
|
12
|
+
classify_direct_payment_confirmation,
|
|
13
|
+
verify_direct_request_payment_webhook,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SiglumeSdrpOrderStore(Protocol):
|
|
18
|
+
async def begin_checkout_attempt(self, order_id: str, request: Request) -> dict[str, Any] | None: ...
|
|
19
|
+
async def mark_checkout_pending(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
order_id: str,
|
|
23
|
+
attempt_id: str,
|
|
24
|
+
stable_nonce: str,
|
|
25
|
+
challenge_hash: str,
|
|
26
|
+
checkout_session_id: str,
|
|
27
|
+
checkout_url: str,
|
|
28
|
+
) -> None: ...
|
|
29
|
+
async def process_webhook_event_once(
|
|
30
|
+
self,
|
|
31
|
+
event_id: str,
|
|
32
|
+
handler: Callable[[], Awaitable[None]],
|
|
33
|
+
) -> Literal["processed", "duplicate"]: ...
|
|
34
|
+
async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None: ...
|
|
35
|
+
async def mark_order_paid_once(self, *, order_id: str, requirement_id: str, chain_receipt_id: str) -> None: ...
|
|
36
|
+
async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None: ...
|
|
37
|
+
async def flag_payment_review(self, data: dict[str, Any]) -> None: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_siglume_sdrp_router(
|
|
41
|
+
order_store: SiglumeSdrpOrderStore,
|
|
42
|
+
*,
|
|
43
|
+
allow_metered_payments: bool = False,
|
|
44
|
+
) -> APIRouter:
|
|
45
|
+
router = APIRouter()
|
|
46
|
+
merchant_key = os.environ["SIGLUME_DIRECT_PAYMENT_MERCHANT"]
|
|
47
|
+
shop_origin = os.environ["SHOP_PUBLIC_ORIGIN"]
|
|
48
|
+
merchant = DirectRequestPaymentMerchantClient(
|
|
49
|
+
auth_token=os.environ["SIGLUME_MERCHANT_AUTH_TOKEN"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@router.post("/checkout/siglume/start")
|
|
53
|
+
async def start_checkout(request: Request) -> JSONResponse:
|
|
54
|
+
body = await request.json()
|
|
55
|
+
order_id = str(body.get("order_id") or "")
|
|
56
|
+
attempt = await order_store.begin_checkout_attempt(order_id, request)
|
|
57
|
+
if not attempt:
|
|
58
|
+
return JSONResponse({"error": "order_not_found"}, status_code=404)
|
|
59
|
+
|
|
60
|
+
if not allow_metered_payments and not _is_standard_checkout_amount(str(attempt["currency"]), int(attempt["amount_minor"])):
|
|
61
|
+
return JSONResponse({"error": "METERED_INTEGRATION_REQUIRED"}, status_code=409)
|
|
62
|
+
|
|
63
|
+
if attempt.get("checkout_url") and attempt.get("checkout_session_id"):
|
|
64
|
+
return JSONResponse({
|
|
65
|
+
"checkout_url": attempt["checkout_url"],
|
|
66
|
+
"session_id": attempt["checkout_session_id"],
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
session = await run_in_threadpool(
|
|
71
|
+
lambda: merchant.create_checkout_session(
|
|
72
|
+
merchant=merchant_key,
|
|
73
|
+
amount_minor=int(attempt["amount_minor"]),
|
|
74
|
+
currency=str(attempt["currency"]),
|
|
75
|
+
nonce=str(attempt["stable_nonce"]),
|
|
76
|
+
success_url=f"{shop_origin}/checkout/siglume/success",
|
|
77
|
+
cancel_url=f"{shop_origin}/checkout/siglume/cancel",
|
|
78
|
+
metadata={"order_id": attempt["order_id"], "attempt_id": attempt["attempt_id"]},
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
except HostedCheckoutNotAvailableError:
|
|
82
|
+
return JSONResponse({"error": "hosted_checkout_not_enabled"}, status_code=409)
|
|
83
|
+
|
|
84
|
+
await order_store.mark_checkout_pending(
|
|
85
|
+
order_id=str(attempt["order_id"]),
|
|
86
|
+
attempt_id=str(attempt["attempt_id"]),
|
|
87
|
+
stable_nonce=str(attempt["stable_nonce"]),
|
|
88
|
+
challenge_hash=session["challenge_hash"],
|
|
89
|
+
checkout_session_id=session["session_id"],
|
|
90
|
+
checkout_url=session["checkout_url"],
|
|
91
|
+
)
|
|
92
|
+
return JSONResponse({"checkout_url": session["checkout_url"], "session_id": session["session_id"]})
|
|
93
|
+
|
|
94
|
+
@router.post("/webhooks/siglume")
|
|
95
|
+
async def siglume_webhook(request: Request) -> Response:
|
|
96
|
+
event = verify_direct_request_payment_webhook(
|
|
97
|
+
os.environ["SIGLUME_WEBHOOK_SECRET"],
|
|
98
|
+
await request.body(),
|
|
99
|
+
request.headers.get("Siglume-Signature", ""),
|
|
100
|
+
)["event"]
|
|
101
|
+
|
|
102
|
+
async def handler() -> None:
|
|
103
|
+
await _process_siglume_webhook_event(
|
|
104
|
+
order_store,
|
|
105
|
+
event,
|
|
106
|
+
allow_metered_payments=allow_metered_payments,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if await order_store.process_webhook_event_once(str(event["id"]), handler) == "duplicate":
|
|
110
|
+
return Response(status_code=204)
|
|
111
|
+
|
|
112
|
+
return Response(status_code=204)
|
|
113
|
+
|
|
114
|
+
return router
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _process_siglume_webhook_event(
|
|
118
|
+
order_store: SiglumeSdrpOrderStore,
|
|
119
|
+
event: dict[str, Any],
|
|
120
|
+
*,
|
|
121
|
+
allow_metered_payments: bool,
|
|
122
|
+
) -> None:
|
|
123
|
+
if event["type"] != "direct_payment.confirmed":
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
confirmation = classify_direct_payment_confirmation(event)
|
|
127
|
+
if confirmation["kind"] == "standard_settled":
|
|
128
|
+
order = await order_store.find_order_by_challenge_hash(confirmation["challenge_hash"])
|
|
129
|
+
if order:
|
|
130
|
+
await order_store.mark_order_paid_once(
|
|
131
|
+
order_id=str(order["id"]),
|
|
132
|
+
requirement_id=confirmation["requirement_id"],
|
|
133
|
+
chain_receipt_id=confirmation["chain_receipt_id"],
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
await order_store.flag_payment_review({
|
|
137
|
+
"reason": "unknown_challenge_hash",
|
|
138
|
+
"requirement_id": confirmation["requirement_id"],
|
|
139
|
+
})
|
|
140
|
+
elif confirmation["kind"] == "metered_usage_accepted":
|
|
141
|
+
if not allow_metered_payments:
|
|
142
|
+
await order_store.flag_payment_review({
|
|
143
|
+
"reason": "metered_integration_required",
|
|
144
|
+
"requirement_id": confirmation["requirement_id"],
|
|
145
|
+
"pricing_band": confirmation["pricing_band"],
|
|
146
|
+
})
|
|
147
|
+
return
|
|
148
|
+
order = await order_store.find_order_by_challenge_hash(confirmation["challenge_hash"])
|
|
149
|
+
if order:
|
|
150
|
+
await order_store.mark_order_fulfilled_unsettled_once(
|
|
151
|
+
order_id=str(order["id"]),
|
|
152
|
+
requirement_id=confirmation["requirement_id"],
|
|
153
|
+
pricing_band=confirmation["pricing_band"],
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
await order_store.flag_payment_review({
|
|
157
|
+
"reason": "unknown_metered_challenge_hash",
|
|
158
|
+
"requirement_id": confirmation["requirement_id"],
|
|
159
|
+
})
|
|
160
|
+
else:
|
|
161
|
+
await order_store.flag_payment_review(dict(confirmation))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_standard_checkout_amount(currency: str, amount_minor: int) -> bool:
|
|
165
|
+
normalized_currency = currency.upper()
|
|
166
|
+
if normalized_currency == "JPY":
|
|
167
|
+
return amount_minor >= 501
|
|
168
|
+
if normalized_currency == "USD":
|
|
169
|
+
return amount_minor >= 301
|
|
170
|
+
return False
|