@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.
- package/CHANGELOG.md +37 -0
- package/README.md +56 -13
- package/dist/index.cjs +61 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +69 -13
- package/dist/index.d.ts +69 -13
- package/dist/index.js +61 -19
- package/dist/index.js.map +1 -1
- package/docs/announcement-ja.md +5 -3
- package/docs/api-reference.md +34 -1
- package/docs/merchant-quickstart.md +27 -6
- package/docs/metered-statements.md +2 -2
- package/docs/payment-lifecycle.md +85 -0
- package/docs/pricing.md +30 -28
- package/docs/quickstart-10-minutes.md +176 -0
- package/docs/troubleshooting.md +62 -0
- package/examples/express-checkout.ts +17 -1
- package/examples/hosted-checkout-python/.env.example +5 -0
- package/examples/hosted-checkout-python/README.md +21 -0
- package/examples/hosted-checkout-python/app.py +124 -0
- package/examples/hosted-checkout-python/order_store.py +42 -0
- package/examples/hosted-checkout-python/pyproject.toml +9 -0
- package/examples/hosted-checkout-typescript/.env.example +5 -0
- package/examples/hosted-checkout-typescript/README.md +21 -0
- package/examples/hosted-checkout-typescript/package.json +20 -0
- package/examples/hosted-checkout-typescript/src/order-store.ts +52 -0
- package/examples/hosted-checkout-typescript/src/server.ts +139 -0
- package/examples/hosted-checkout-typescript/tsconfig.json +13 -0
- package/package.json +4 -1
|
@@ -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,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.
|
|
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"
|