@siglume/direct-request-payment 0.4.16 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from dotenv import load_dotenv
6
+ from flask import Flask, jsonify, request
7
+ from siglume_direct_request_payment import (
8
+ DirectRequestPaymentMerchantClient,
9
+ HostedCheckoutNotAvailableError,
10
+ classify_direct_payment_confirmation,
11
+ verify_direct_request_payment_webhook,
12
+ )
13
+
14
+ from order_store import (
15
+ all_orders,
16
+ find_order_by_challenge_hash,
17
+ get_order,
18
+ mark_webhook_event_processed_once,
19
+ save_order,
20
+ )
21
+
22
+ load_dotenv()
23
+
24
+ app = Flask(__name__)
25
+ merchant_key = os.environ.get("SIGLUME_DIRECT_PAYMENT_MERCHANT", "example_merchant")
26
+ shop_origin = os.environ.get("SHOP_PUBLIC_ORIGIN", "https://www.example.com")
27
+ siglume_merchant = DirectRequestPaymentMerchantClient(
28
+ auth_token=os.environ.get("SIGLUME_MERCHANT_AUTH_TOKEN"),
29
+ )
30
+
31
+
32
+ @app.get("/orders")
33
+ def orders():
34
+ return jsonify({"orders": all_orders()})
35
+
36
+
37
+ @app.post("/checkout/siglume/start")
38
+ def start_checkout():
39
+ order_id = str((request.get_json(silent=True) or {}).get("order_id") or "")
40
+ order = get_order(order_id)
41
+ if order is None:
42
+ return jsonify({"error": "order_not_found"}), 404
43
+
44
+ order["payment_attempt"] = int(order.get("payment_attempt") or 0) + 1
45
+ session = siglume_merchant.create_checkout_session(
46
+ merchant=merchant_key,
47
+ amount_minor=int(order["amount_minor"]),
48
+ currency=str(order["currency"]),
49
+ nonce=f"{order['id']}-attempt_{order['payment_attempt']}",
50
+ success_url=f"{shop_origin}/thanks",
51
+ cancel_url=f"{shop_origin}/cart",
52
+ metadata={"order_id": order["id"]},
53
+ )
54
+
55
+ order["siglume_challenge_hash"] = session["challenge_hash"]
56
+ order["siglume_checkout_session_id"] = session["session_id"]
57
+ order["siglume_payment_status"] = "pending"
58
+ save_order(order)
59
+
60
+ return jsonify(
61
+ {
62
+ "order_id": order["id"],
63
+ "amount_minor": order["amount_minor"],
64
+ "currency": order["currency"],
65
+ "checkout_url": session["checkout_url"],
66
+ "session_id": session["session_id"],
67
+ }
68
+ )
69
+
70
+
71
+ @app.post("/siglume/webhook")
72
+ def siglume_webhook():
73
+ verified = verify_direct_request_payment_webhook(
74
+ os.environ.get("SIGLUME_WEBHOOK_SECRET", ""),
75
+ request.get_data(),
76
+ request.headers.get("Siglume-Signature", ""),
77
+ )
78
+ event = verified["event"]
79
+
80
+ if not mark_webhook_event_processed_once(str(event["id"])):
81
+ return "", 204
82
+
83
+ if event["type"] == "direct_payment.confirmed":
84
+ confirmation = classify_direct_payment_confirmation(event)
85
+
86
+ if confirmation["kind"] == "standard_settled":
87
+ order = find_order_by_challenge_hash(confirmation["challenge_hash"])
88
+ if order is not None:
89
+ order["siglume_payment_status"] = "paid"
90
+ order["siglume_requirement_id"] = confirmation["requirement_id"]
91
+ order["siglume_chain_receipt_id"] = confirmation["chain_receipt_id"]
92
+ save_order(order)
93
+ elif confirmation["kind"] == "metered_usage_accepted":
94
+ order = find_order_by_challenge_hash(confirmation["challenge_hash"])
95
+ if order is not None:
96
+ order["siglume_payment_status"] = "fulfilled_unsettled"
97
+ order["siglume_requirement_id"] = confirmation["requirement_id"]
98
+ save_order(order)
99
+ else:
100
+ app.logger.warning(
101
+ "manual payment review required",
102
+ extra={
103
+ "event_id": event["id"],
104
+ "reason": confirmation.get("reason"),
105
+ "requirement_id": confirmation.get("requirement_id"),
106
+ },
107
+ )
108
+
109
+ return "", 204
110
+
111
+
112
+ @app.errorhandler(HostedCheckoutNotAvailableError)
113
+ def hosted_checkout_not_available(_: HostedCheckoutNotAvailableError):
114
+ return jsonify({"error": "hosted_checkout_not_enabled"}), 409
115
+
116
+
117
+ @app.errorhandler(Exception)
118
+ def internal_error(error: Exception):
119
+ app.logger.error("checkout starter error", extra={"name": type(error).__name__})
120
+ return jsonify({"error": "internal_error"}), 500
121
+
122
+
123
+ if __name__ == "__main__":
124
+ app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "3000")))
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ Order = dict[str, Any]
6
+
7
+ _orders: dict[str, Order] = {
8
+ "order_123": {
9
+ "id": "order_123",
10
+ "amount_minor": 1200,
11
+ "currency": "JPY",
12
+ "payment_attempt": 0,
13
+ "siglume_payment_status": "created",
14
+ }
15
+ }
16
+ _processed_webhook_events: set[str] = set()
17
+
18
+
19
+ def get_order(order_id: str) -> Order | None:
20
+ return _orders.get(order_id)
21
+
22
+
23
+ def all_orders() -> list[Order]:
24
+ return list(_orders.values())
25
+
26
+
27
+ def save_order(order: Order) -> None:
28
+ _orders[str(order["id"])] = order
29
+
30
+
31
+ def find_order_by_challenge_hash(challenge_hash: str) -> Order | None:
32
+ for order in _orders.values():
33
+ if order.get("siglume_challenge_hash") == challenge_hash:
34
+ return order
35
+ return None
36
+
37
+
38
+ def mark_webhook_event_processed_once(event_id: str) -> bool:
39
+ if event_id in _processed_webhook_events:
40
+ return False
41
+ _processed_webhook_events.add(event_id)
42
+ return True
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = "siglume-hosted-checkout-python-starter"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.11"
5
+ dependencies = [
6
+ "Flask>=3.0,<4",
7
+ "python-dotenv>=1.0,<2",
8
+ "siglume-direct-request-payment>=0.4.18,<0.5",
9
+ ]
@@ -0,0 +1,5 @@
1
+ SIGLUME_MERCHANT_AUTH_TOKEN=
2
+ SIGLUME_DIRECT_PAYMENT_MERCHANT=example_merchant
3
+ SIGLUME_WEBHOOK_SECRET=
4
+ SHOP_PUBLIC_ORIGIN=https://www.example.com
5
+ PORT=3000
@@ -0,0 +1,21 @@
1
+ # Hosted Checkout TypeScript Starter
2
+
3
+ Minimal Express starter for one Standard Payment test order.
4
+
5
+ ```bash
6
+ cp .env.example .env
7
+ npm install
8
+ npm run dev
9
+ ```
10
+
11
+ Then call:
12
+
13
+ ```bash
14
+ curl -X POST http://localhost:3000/checkout/siglume/start \
15
+ -H "content-type: application/json" \
16
+ -d "{\"order_id\":\"order_123\"}"
17
+ ```
18
+
19
+ This starter uses in-memory storage so it is easy to inspect. Replace it with a
20
+ database before production. Production systems must persist orders, processed
21
+ webhook event ids, and fulfillment state in one durable transaction.
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "siglume-hosted-checkout-typescript-starter",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "tsx src/server.ts",
7
+ "typecheck": "tsc --noEmit"
8
+ },
9
+ "dependencies": {
10
+ "@siglume/direct-request-payment": "file:../..",
11
+ "dotenv": "^16.4.7",
12
+ "express": "^4.21.2"
13
+ },
14
+ "devDependencies": {
15
+ "@types/express": "^4.17.21",
16
+ "@types/node": "^20.16.5",
17
+ "tsx": "^4.19.2",
18
+ "typescript": "^5.6.3"
19
+ }
20
+ }
@@ -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.16",
3
+ "version": "0.4.18",
4
4
  "description": "SDK for the Siglume Direct Request Payment SDRP payment protocol",
5
5
  "keywords": [
6
6
  "siglume",
@@ -54,6 +54,9 @@
54
54
  "dist",
55
55
  "docs",
56
56
  "examples",
57
+ "!examples/**/node_modules",
58
+ "!examples/**/__pycache__",
59
+ "!examples/**/*.pyc",
57
60
  "README.md",
58
61
  "CHANGELOG.md",
59
62
  "LICENSE"