@siglume/direct-request-payment 0.4.17 → 0.4.19

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +60 -4
  3. package/bin/siglume-sdrp.mjs +267 -0
  4. package/dist/index.cjs +3 -3
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +3 -3
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +3 -3
  9. package/dist/index.js.map +1 -1
  10. package/docs/announcement-ja.md +5 -3
  11. package/docs/api-reference.md +34 -1
  12. package/docs/merchant-quickstart.md +19 -2
  13. package/docs/payment-lifecycle.md +85 -0
  14. package/docs/pricing.md +6 -6
  15. package/docs/quickstart-10-minutes.md +145 -0
  16. package/docs/troubleshooting.md +70 -0
  17. package/examples/express-checkout.ts +14 -0
  18. package/examples/hosted-checkout-python/.env.example +5 -0
  19. package/examples/hosted-checkout-python/README.md +21 -0
  20. package/examples/hosted-checkout-python/app.py +124 -0
  21. package/examples/hosted-checkout-python/order_store.py +42 -0
  22. package/examples/hosted-checkout-python/pyproject.toml +9 -0
  23. package/examples/hosted-checkout-typescript/.env.example +5 -0
  24. package/examples/hosted-checkout-typescript/README.md +21 -0
  25. package/examples/hosted-checkout-typescript/package.json +20 -0
  26. package/examples/hosted-checkout-typescript/src/order-store.ts +52 -0
  27. package/examples/hosted-checkout-typescript/src/server.ts +139 -0
  28. package/examples/hosted-checkout-typescript/tsconfig.json +13 -0
  29. package/package.json +12 -1
  30. package/templates/express/README.md +22 -0
  31. package/templates/express/siglume-order-store.example.ts +53 -0
  32. package/templates/express/siglume-sdrp-routes.ts +157 -0
  33. package/templates/fastapi/README.md +22 -0
  34. package/templates/fastapi/siglume_order_store_example.py +54 -0
  35. package/templates/fastapi/siglume_sdrp_routes.py +107 -0
@@ -0,0 +1,52 @@
1
+ export type OrderStatus = "created" | "pending" | "paid" | "fulfilled_unsettled" | "review_required";
2
+
3
+ export interface Order {
4
+ id: string;
5
+ amount_minor: number;
6
+ currency: "JPY" | "USD";
7
+ payment_attempt: number;
8
+ siglume_challenge_hash?: string;
9
+ siglume_checkout_session_id?: string;
10
+ siglume_requirement_id?: string;
11
+ siglume_chain_receipt_id?: string;
12
+ siglume_payment_status: OrderStatus;
13
+ }
14
+
15
+ const orders = new Map<string, Order>([
16
+ [
17
+ "order_123",
18
+ {
19
+ id: "order_123",
20
+ amount_minor: 1200,
21
+ currency: "JPY",
22
+ payment_attempt: 0,
23
+ siglume_payment_status: "created",
24
+ },
25
+ ],
26
+ ]);
27
+
28
+ const processedWebhookEvents = new Set<string>();
29
+
30
+ export function getOrder(orderId: string): Order | undefined {
31
+ return orders.get(orderId);
32
+ }
33
+
34
+ export function allOrders(): Order[] {
35
+ return [...orders.values()];
36
+ }
37
+
38
+ export function saveOrder(order: Order): void {
39
+ orders.set(order.id, order);
40
+ }
41
+
42
+ export function findOrderByChallengeHash(challengeHash: string): Order | undefined {
43
+ return [...orders.values()].find((order) => order.siglume_challenge_hash === challengeHash);
44
+ }
45
+
46
+ export function markWebhookEventProcessedOnce(eventId: string): boolean {
47
+ if (processedWebhookEvents.has(eventId)) {
48
+ return false;
49
+ }
50
+ processedWebhookEvents.add(eventId);
51
+ return true;
52
+ }
@@ -0,0 +1,139 @@
1
+ import "dotenv/config";
2
+
3
+ import express from "express";
4
+ import {
5
+ classifyDirectPaymentConfirmation,
6
+ DirectRequestPaymentMerchantClient,
7
+ HostedCheckoutNotAvailableError,
8
+ verifyDirectRequestPaymentWebhook,
9
+ } from "@siglume/direct-request-payment";
10
+
11
+ import {
12
+ allOrders,
13
+ findOrderByChallengeHash,
14
+ getOrder,
15
+ markWebhookEventProcessedOnce,
16
+ saveOrder,
17
+ } from "./order-store.js";
18
+
19
+ const app = express();
20
+ const port = Number(process.env.PORT || 3000);
21
+ const merchantKey = process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT || "example_merchant";
22
+ const shopOrigin = process.env.SHOP_PUBLIC_ORIGIN || "https://www.example.com";
23
+
24
+ const siglumeMerchant = new DirectRequestPaymentMerchantClient({
25
+ auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN,
26
+ });
27
+
28
+ const asyncRoute =
29
+ (handler: express.RequestHandler): express.RequestHandler =>
30
+ (req, res, next) => {
31
+ Promise.resolve(handler(req, res, next)).catch(next);
32
+ };
33
+
34
+ app.get("/orders", (_req, res) => {
35
+ res.json({ orders: allOrders() });
36
+ });
37
+
38
+ app.post(
39
+ "/checkout/siglume/start",
40
+ express.json(),
41
+ asyncRoute(async (req, res) => {
42
+ const orderId = String(req.body?.order_id || "");
43
+ const order = getOrder(orderId);
44
+ if (!order) {
45
+ res.status(404).json({ error: "order_not_found" });
46
+ return;
47
+ }
48
+
49
+ order.payment_attempt += 1;
50
+ const session = await siglumeMerchant.createCheckoutSession({
51
+ merchant: merchantKey,
52
+ amount_minor: order.amount_minor,
53
+ currency: order.currency,
54
+ nonce: `${order.id}-attempt_${order.payment_attempt}`,
55
+ success_url: `${shopOrigin}/thanks`,
56
+ cancel_url: `${shopOrigin}/cart`,
57
+ metadata: { order_id: order.id },
58
+ });
59
+
60
+ order.siglume_challenge_hash = session.challenge_hash;
61
+ order.siglume_checkout_session_id = session.session_id;
62
+ order.siglume_payment_status = "pending";
63
+ saveOrder(order);
64
+
65
+ res.json({
66
+ order_id: order.id,
67
+ amount_minor: order.amount_minor,
68
+ currency: order.currency,
69
+ checkout_url: session.checkout_url,
70
+ session_id: session.session_id,
71
+ });
72
+ }),
73
+ );
74
+
75
+ app.post(
76
+ "/siglume/webhook",
77
+ express.raw({ type: "application/json" }),
78
+ asyncRoute(async (req, res) => {
79
+ const { event } = await verifyDirectRequestPaymentWebhook(
80
+ process.env.SIGLUME_WEBHOOK_SECRET || "",
81
+ req.body,
82
+ req.header("siglume-signature") || "",
83
+ );
84
+
85
+ if (!markWebhookEventProcessedOnce(event.id)) {
86
+ res.status(204).send();
87
+ return;
88
+ }
89
+
90
+ if (event.type === "direct_payment.confirmed") {
91
+ const confirmation = classifyDirectPaymentConfirmation(event);
92
+
93
+ if (confirmation.kind === "standard_settled") {
94
+ const order = findOrderByChallengeHash(confirmation.challenge_hash);
95
+ if (order) {
96
+ order.siglume_payment_status = "paid";
97
+ order.siglume_requirement_id = confirmation.requirement_id;
98
+ order.siglume_chain_receipt_id = confirmation.chain_receipt_id;
99
+ saveOrder(order);
100
+ }
101
+ } else if (confirmation.kind === "metered_usage_accepted") {
102
+ const order = findOrderByChallengeHash(confirmation.challenge_hash);
103
+ if (order) {
104
+ order.siglume_payment_status = "fulfilled_unsettled";
105
+ order.siglume_requirement_id = confirmation.requirement_id;
106
+ saveOrder(order);
107
+ }
108
+ } else if (confirmation.kind === "metered_batch_settled") {
109
+ console.info("metered batch settled", {
110
+ settlement_batch_id: confirmation.settlement_batch_id,
111
+ chain_receipt_id: confirmation.chain_receipt_id,
112
+ });
113
+ } else {
114
+ console.warn("manual payment review required", {
115
+ event_id: event.id,
116
+ reason: confirmation.reason,
117
+ requirement_id: confirmation.requirement_id,
118
+ });
119
+ }
120
+ }
121
+
122
+ res.status(204).send();
123
+ }),
124
+ );
125
+
126
+ app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
127
+ if (error instanceof HostedCheckoutNotAvailableError) {
128
+ res.status(409).json({ error: "hosted_checkout_not_enabled" });
129
+ return;
130
+ }
131
+ console.error("checkout starter error:", {
132
+ name: error instanceof Error ? error.name : "Error",
133
+ });
134
+ res.status(500).json({ error: "internal_error" });
135
+ });
136
+
137
+ app.listen(port, () => {
138
+ console.log(`Siglume Hosted Checkout starter listening on http://localhost:${port}`);
139
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "skipLibCheck": true,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siglume/direct-request-payment",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "description": "SDK for the Siglume Direct Request Payment SDRP payment protocol",
5
5
  "keywords": [
6
6
  "siglume",
@@ -33,6 +33,10 @@
33
33
  "main": "./dist/index.cjs",
34
34
  "module": "./dist/index.js",
35
35
  "types": "./dist/index.d.ts",
36
+ "bin": {
37
+ "siglume-sdrp": "./bin/siglume-sdrp.mjs",
38
+ "siglume-check": "./bin/siglume-sdrp.mjs"
39
+ },
36
40
  "exports": {
37
41
  ".": {
38
42
  "types": "./dist/index.d.ts",
@@ -52,8 +56,15 @@
52
56
  },
53
57
  "files": [
54
58
  "dist",
59
+ "bin",
55
60
  "docs",
56
61
  "examples",
62
+ "templates",
63
+ "!examples/**/node_modules",
64
+ "!examples/**/__pycache__",
65
+ "!examples/**/*.pyc",
66
+ "!templates/**/__pycache__",
67
+ "!templates/**/*.pyc",
57
68
  "README.md",
58
69
  "CHANGELOG.md",
59
70
  "LICENSE"
@@ -0,0 +1,22 @@
1
+ # Express Integration Files
2
+
3
+ Mount the router in your existing app:
4
+
5
+ ```ts
6
+ import { createSiglumeSdrpRouter } from "./siglume/siglume-sdrp-routes.js";
7
+ import { siglumeOrderStore } from "./siglume/siglume-order-store.example.js";
8
+
9
+ app.use("/payments", createSiglumeSdrpRouter({
10
+ merchant: process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT!,
11
+ merchant_auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN!,
12
+ webhook_secret: process.env.SIGLUME_WEBHOOK_SECRET!,
13
+ shop_public_origin: process.env.SHOP_PUBLIC_ORIGIN!,
14
+ order_store: siglumeOrderStore,
15
+ }));
16
+ ```
17
+
18
+ Replace `siglume-order-store.example.ts` with your real order database adapter.
19
+ The route paths become:
20
+
21
+ - `POST /payments/checkout/siglume/start`
22
+ - `POST /payments/webhooks/siglume`
@@ -0,0 +1,53 @@
1
+ import type { Request } from "express";
2
+
3
+ import type { SiglumeCheckoutOrder, SiglumeSdrpOrderStore } from "./siglume-sdrp-routes.js";
4
+
5
+ type Order = SiglumeCheckoutOrder & {
6
+ status: "created" | "pending" | "paid" | "fulfilled_unsettled" | "review_required";
7
+ challenge_hash?: string;
8
+ checkout_session_id?: string;
9
+ requirement_id?: string;
10
+ chain_receipt_id?: string;
11
+ };
12
+
13
+ const orders = new Map<string, Order>([
14
+ ["order_123", { id: "order_123", amount_minor: 1200, currency: "JPY", status: "created" }],
15
+ ]);
16
+ const processedEvents = new Set<string>();
17
+
18
+ export const siglumeOrderStore: SiglumeSdrpOrderStore = {
19
+ async getOrderForCheckout(orderId: string, _req: Request) {
20
+ return orders.get(orderId) || null;
21
+ },
22
+ async markCheckoutPending(input) {
23
+ const order = orders.get(input.order_id);
24
+ if (!order) return;
25
+ order.status = "pending";
26
+ order.challenge_hash = input.challenge_hash;
27
+ order.checkout_session_id = input.checkout_session_id;
28
+ },
29
+ async recordWebhookEventOnce(eventId) {
30
+ if (processedEvents.has(eventId)) return false;
31
+ processedEvents.add(eventId);
32
+ return true;
33
+ },
34
+ async findOrderByChallengeHash(challengeHash) {
35
+ return [...orders.values()].find((order) => order.challenge_hash === challengeHash) || null;
36
+ },
37
+ async markOrderPaidOnce(input) {
38
+ const order = orders.get(input.order_id);
39
+ if (!order || order.status === "paid") return;
40
+ order.status = "paid";
41
+ order.requirement_id = input.requirement_id;
42
+ order.chain_receipt_id = input.chain_receipt_id;
43
+ },
44
+ async markOrderFulfilledUnsettledOnce(input) {
45
+ const order = orders.get(input.order_id);
46
+ if (!order || order.status === "fulfilled_unsettled") return;
47
+ order.status = "fulfilled_unsettled";
48
+ order.requirement_id = input.requirement_id;
49
+ },
50
+ async flagPaymentReview(input) {
51
+ console.warn("payment review required", input);
52
+ },
53
+ };
@@ -0,0 +1,157 @@
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 SiglumeSdrpOrderStore {
17
+ getOrderForCheckout(orderId: string, req: express.Request): Promise<SiglumeCheckoutOrder | null>;
18
+ markCheckoutPending(input: {
19
+ order_id: string;
20
+ challenge_hash: string;
21
+ checkout_session_id: string;
22
+ }): Promise<void>;
23
+ recordWebhookEventOnce(eventId: string): Promise<boolean>;
24
+ findOrderByChallengeHash(challengeHash: string): Promise<{ id: string } | null>;
25
+ markOrderPaidOnce(input: {
26
+ order_id: string;
27
+ requirement_id: string;
28
+ chain_receipt_id: string;
29
+ }): Promise<void>;
30
+ markOrderFulfilledUnsettledOnce(input: {
31
+ order_id: string;
32
+ requirement_id: string;
33
+ pricing_band: string;
34
+ }): Promise<void>;
35
+ flagPaymentReview(input: Record<string, unknown>): Promise<void>;
36
+ }
37
+
38
+ export interface SiglumeSdrpRouterOptions {
39
+ merchant: string;
40
+ merchant_auth_token: string;
41
+ webhook_secret: string;
42
+ shop_public_origin: string;
43
+ order_store: SiglumeSdrpOrderStore;
44
+ }
45
+
46
+ export function createSiglumeSdrpRouter(options: SiglumeSdrpRouterOptions): express.Router {
47
+ const router = express.Router();
48
+ const merchant = new DirectRequestPaymentMerchantClient({
49
+ auth_token: options.merchant_auth_token,
50
+ });
51
+
52
+ router.post("/checkout/siglume/start", express.json(), async (req, res, next) => {
53
+ try {
54
+ const orderId = String(req.body?.order_id || "");
55
+ const order = await options.order_store.getOrderForCheckout(orderId, req);
56
+ if (!order) {
57
+ res.status(404).json({ error: "order_not_found" });
58
+ return;
59
+ }
60
+
61
+ const session = await merchant.createCheckoutSession({
62
+ merchant: options.merchant,
63
+ amount_minor: order.amount_minor,
64
+ currency: order.currency,
65
+ nonce: `${order.id}-attempt_${Date.now()}`,
66
+ success_url: `${options.shop_public_origin}/checkout/siglume/success`,
67
+ cancel_url: `${options.shop_public_origin}/checkout/siglume/cancel`,
68
+ metadata: { order_id: order.id },
69
+ });
70
+
71
+ await options.order_store.markCheckoutPending({
72
+ order_id: order.id,
73
+ challenge_hash: session.challenge_hash,
74
+ checkout_session_id: session.session_id,
75
+ });
76
+
77
+ res.json({ checkout_url: session.checkout_url, session_id: session.session_id });
78
+ } catch (error) {
79
+ next(error);
80
+ }
81
+ });
82
+
83
+ router.post("/webhooks/siglume", express.raw({ type: "application/json" }), async (req, res, next) => {
84
+ try {
85
+ const { event } = await verifyDirectRequestPaymentWebhook(
86
+ options.webhook_secret,
87
+ req.body,
88
+ req.header("siglume-signature") || "",
89
+ );
90
+
91
+ if (!(await options.order_store.recordWebhookEventOnce(event.id))) {
92
+ res.status(204).send();
93
+ return;
94
+ }
95
+
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
+ res.status(204).send();
143
+ } catch (error) {
144
+ next(error);
145
+ }
146
+ });
147
+
148
+ router.use((error: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
149
+ if (error instanceof HostedCheckoutNotAvailableError) {
150
+ res.status(409).json({ error: "hosted_checkout_not_enabled" });
151
+ return;
152
+ }
153
+ next(error);
154
+ });
155
+
156
+ return router;
157
+ }
@@ -0,0 +1,22 @@
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()),
14
+ prefix="/payments",
15
+ )
16
+ ```
17
+
18
+ Replace `siglume_order_store_example.py` with your real order database adapter.
19
+ The route paths become:
20
+
21
+ - `POST /payments/checkout/siglume/start`
22
+ - `POST /payments/webhooks/siglume`
@@ -0,0 +1,54 @@
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 get_order_for_checkout(self, order_id: str, request: Request) -> dict[str, Any] | None:
15
+ return _orders.get(order_id)
16
+
17
+ async def mark_checkout_pending(self, *, order_id: str, challenge_hash: str, checkout_session_id: str) -> None:
18
+ order = _orders.get(order_id)
19
+ if not order:
20
+ return
21
+ order["status"] = "pending"
22
+ order["challenge_hash"] = challenge_hash
23
+ order["checkout_session_id"] = checkout_session_id
24
+
25
+ async def record_webhook_event_once(self, event_id: str) -> bool:
26
+ if event_id in _processed_events:
27
+ return False
28
+ _processed_events.add(event_id)
29
+ return True
30
+
31
+ async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None:
32
+ for order in _orders.values():
33
+ if order.get("challenge_hash") == challenge_hash:
34
+ return order
35
+ return None
36
+
37
+ async def mark_order_paid_once(self, *, order_id: str, requirement_id: str, chain_receipt_id: str) -> None:
38
+ order = _orders.get(order_id)
39
+ if not order or order.get("status") == "paid":
40
+ return
41
+ order["status"] = "paid"
42
+ order["requirement_id"] = requirement_id
43
+ order["chain_receipt_id"] = chain_receipt_id
44
+
45
+ async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None:
46
+ order = _orders.get(order_id)
47
+ if not order or order.get("status") == "fulfilled_unsettled":
48
+ return
49
+ order["status"] = "fulfilled_unsettled"
50
+ order["requirement_id"] = requirement_id
51
+ order["pricing_band"] = pricing_band
52
+
53
+ async def flag_payment_review(self, data: dict[str, Any]) -> None:
54
+ print("payment review required", data)
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from typing import Any, Protocol
6
+
7
+ from fastapi import APIRouter, Request
8
+ from fastapi.responses import JSONResponse, Response
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 get_order_for_checkout(self, order_id: str, request: Request) -> dict[str, Any] | None: ...
19
+ async def mark_checkout_pending(self, *, order_id: str, challenge_hash: str, checkout_session_id: str) -> None: ...
20
+ async def record_webhook_event_once(self, event_id: str) -> bool: ...
21
+ async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None: ...
22
+ async def mark_order_paid_once(self, *, order_id: str, requirement_id: str, chain_receipt_id: str) -> None: ...
23
+ async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None: ...
24
+ async def flag_payment_review(self, data: dict[str, Any]) -> None: ...
25
+
26
+
27
+ def create_siglume_sdrp_router(order_store: SiglumeSdrpOrderStore) -> APIRouter:
28
+ router = APIRouter()
29
+ merchant_key = os.environ["SIGLUME_DIRECT_PAYMENT_MERCHANT"]
30
+ shop_origin = os.environ["SHOP_PUBLIC_ORIGIN"]
31
+ merchant = DirectRequestPaymentMerchantClient(
32
+ auth_token=os.environ["SIGLUME_MERCHANT_AUTH_TOKEN"],
33
+ )
34
+
35
+ @router.post("/checkout/siglume/start")
36
+ async def start_checkout(request: Request) -> JSONResponse:
37
+ body = await request.json()
38
+ order_id = str(body.get("order_id") or "")
39
+ order = await order_store.get_order_for_checkout(order_id, request)
40
+ if not order:
41
+ return JSONResponse({"error": "order_not_found"}, status_code=404)
42
+
43
+ try:
44
+ session = merchant.create_checkout_session(
45
+ merchant=merchant_key,
46
+ amount_minor=int(order["amount_minor"]),
47
+ currency=str(order["currency"]),
48
+ nonce=f"{order['id']}-attempt_{int(time.time() * 1000)}",
49
+ success_url=f"{shop_origin}/checkout/siglume/success",
50
+ cancel_url=f"{shop_origin}/checkout/siglume/cancel",
51
+ metadata={"order_id": order["id"]},
52
+ )
53
+ except HostedCheckoutNotAvailableError:
54
+ return JSONResponse({"error": "hosted_checkout_not_enabled"}, status_code=409)
55
+
56
+ await order_store.mark_checkout_pending(
57
+ order_id=str(order["id"]),
58
+ challenge_hash=session["challenge_hash"],
59
+ checkout_session_id=session["session_id"],
60
+ )
61
+ return JSONResponse({"checkout_url": session["checkout_url"], "session_id": session["session_id"]})
62
+
63
+ @router.post("/webhooks/siglume")
64
+ async def siglume_webhook(request: Request) -> Response:
65
+ event = verify_direct_request_payment_webhook(
66
+ os.environ["SIGLUME_WEBHOOK_SECRET"],
67
+ await request.body(),
68
+ request.headers.get("Siglume-Signature", ""),
69
+ )["event"]
70
+
71
+ if not await order_store.record_webhook_event_once(str(event["id"])):
72
+ return Response(status_code=204)
73
+
74
+ if event["type"] == "direct_payment.confirmed":
75
+ confirmation = classify_direct_payment_confirmation(event)
76
+ if confirmation["kind"] == "standard_settled":
77
+ order = await order_store.find_order_by_challenge_hash(confirmation["challenge_hash"])
78
+ if order:
79
+ await order_store.mark_order_paid_once(
80
+ order_id=str(order["id"]),
81
+ requirement_id=confirmation["requirement_id"],
82
+ chain_receipt_id=confirmation["chain_receipt_id"],
83
+ )
84
+ else:
85
+ await order_store.flag_payment_review({
86
+ "reason": "unknown_challenge_hash",
87
+ "requirement_id": confirmation["requirement_id"],
88
+ })
89
+ elif confirmation["kind"] == "metered_usage_accepted":
90
+ order = await order_store.find_order_by_challenge_hash(confirmation["challenge_hash"])
91
+ if order:
92
+ await order_store.mark_order_fulfilled_unsettled_once(
93
+ order_id=str(order["id"]),
94
+ requirement_id=confirmation["requirement_id"],
95
+ pricing_band=confirmation["pricing_band"],
96
+ )
97
+ else:
98
+ await order_store.flag_payment_review({
99
+ "reason": "unknown_metered_challenge_hash",
100
+ "requirement_id": confirmation["requirement_id"],
101
+ })
102
+ else:
103
+ await order_store.flag_payment_review(dict(confirmation))
104
+
105
+ return Response(status_code=204)
106
+
107
+ return router