@siglume/direct-request-payment 0.4.19 → 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 +25 -0
- package/README.md +12 -9
- package/bin/siglume-sdrp.mjs +135 -4
- 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 +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 +46 -17
- package/docs/troubleshooting.md +15 -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 +1 -1
- package/templates/express/README.md +24 -4
- package/templates/express/siglume-order-store.example.ts +22 -6
- package/templates/express/siglume-sdrp-routes.ts +138 -64
- package/templates/fastapi/README.md +5 -1
- package/templates/fastapi/siglume_order_store_example.py +29 -6
- package/templates/fastapi/siglume_sdrp_routes.py +112 -49
|
@@ -13,9 +13,9 @@ from siglume_direct_request_payment import (
|
|
|
13
13
|
|
|
14
14
|
from order_store import (
|
|
15
15
|
all_orders,
|
|
16
|
+
begin_checkout_attempt,
|
|
16
17
|
find_order_by_challenge_hash,
|
|
17
|
-
|
|
18
|
-
mark_webhook_event_processed_once,
|
|
18
|
+
process_webhook_event_once,
|
|
19
19
|
save_order,
|
|
20
20
|
)
|
|
21
21
|
|
|
@@ -37,11 +37,21 @@ def orders():
|
|
|
37
37
|
@app.post("/checkout/siglume/start")
|
|
38
38
|
def start_checkout():
|
|
39
39
|
order_id = str((request.get_json(silent=True) or {}).get("order_id") or "")
|
|
40
|
-
order =
|
|
40
|
+
order = begin_checkout_attempt(order_id)
|
|
41
41
|
if order is None:
|
|
42
42
|
return jsonify({"error": "order_not_found"}), 404
|
|
43
43
|
|
|
44
|
-
order
|
|
44
|
+
if order.get("siglume_checkout_url") and order.get("siglume_checkout_session_id"):
|
|
45
|
+
return jsonify(
|
|
46
|
+
{
|
|
47
|
+
"order_id": order["id"],
|
|
48
|
+
"amount_minor": order["amount_minor"],
|
|
49
|
+
"currency": order["currency"],
|
|
50
|
+
"checkout_url": order["siglume_checkout_url"],
|
|
51
|
+
"session_id": order["siglume_checkout_session_id"],
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
45
55
|
session = siglume_merchant.create_checkout_session(
|
|
46
56
|
merchant=merchant_key,
|
|
47
57
|
amount_minor=int(order["amount_minor"]),
|
|
@@ -53,6 +63,7 @@ def start_checkout():
|
|
|
53
63
|
)
|
|
54
64
|
|
|
55
65
|
order["siglume_challenge_hash"] = session["challenge_hash"]
|
|
66
|
+
order["siglume_checkout_url"] = session["checkout_url"]
|
|
56
67
|
order["siglume_checkout_session_id"] = session["session_id"]
|
|
57
68
|
order["siglume_payment_status"] = "pending"
|
|
58
69
|
save_order(order)
|
|
@@ -77,35 +88,39 @@ def siglume_webhook():
|
|
|
77
88
|
)
|
|
78
89
|
event = verified["event"]
|
|
79
90
|
|
|
80
|
-
|
|
91
|
+
def handler() -> None:
|
|
92
|
+
if event["type"] == "direct_payment.confirmed":
|
|
93
|
+
confirmation = classify_direct_payment_confirmation(event)
|
|
94
|
+
|
|
95
|
+
if confirmation["kind"] == "standard_settled":
|
|
96
|
+
order = find_order_by_challenge_hash(confirmation["challenge_hash"])
|
|
97
|
+
if order is not None:
|
|
98
|
+
order["siglume_payment_status"] = "paid"
|
|
99
|
+
order["siglume_requirement_id"] = confirmation["requirement_id"]
|
|
100
|
+
order["siglume_chain_receipt_id"] = confirmation["chain_receipt_id"]
|
|
101
|
+
save_order(order)
|
|
102
|
+
elif confirmation["kind"] == "metered_usage_accepted":
|
|
103
|
+
app.logger.warning(
|
|
104
|
+
"Micro/Nano settlement integration is required before automatic fulfillment",
|
|
105
|
+
extra={
|
|
106
|
+
"event_id": event["id"],
|
|
107
|
+
"requirement_id": confirmation["requirement_id"],
|
|
108
|
+
"pricing_band": confirmation["pricing_band"],
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
app.logger.warning(
|
|
113
|
+
"manual payment review required",
|
|
114
|
+
extra={
|
|
115
|
+
"event_id": event["id"],
|
|
116
|
+
"reason": confirmation.get("reason"),
|
|
117
|
+
"requirement_id": confirmation.get("requirement_id"),
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if process_webhook_event_once(str(event["id"]), handler) == "duplicate":
|
|
81
122
|
return "", 204
|
|
82
123
|
|
|
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
124
|
return "", 204
|
|
110
125
|
|
|
111
126
|
|
|
@@ -20,6 +20,15 @@ def get_order(order_id: str) -> Order | None:
|
|
|
20
20
|
return _orders.get(order_id)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def begin_checkout_attempt(order_id: str) -> Order | None:
|
|
24
|
+
order = _orders.get(order_id)
|
|
25
|
+
if order is None:
|
|
26
|
+
return None
|
|
27
|
+
if not int(order.get("payment_attempt") or 0):
|
|
28
|
+
order["payment_attempt"] = 1
|
|
29
|
+
return order
|
|
30
|
+
|
|
31
|
+
|
|
23
32
|
def all_orders() -> list[Order]:
|
|
24
33
|
return list(_orders.values())
|
|
25
34
|
|
|
@@ -35,8 +44,9 @@ def find_order_by_challenge_hash(challenge_hash: str) -> Order | None:
|
|
|
35
44
|
return None
|
|
36
45
|
|
|
37
46
|
|
|
38
|
-
def
|
|
47
|
+
def process_webhook_event_once(event_id: str, handler) -> str:
|
|
39
48
|
if event_id in _processed_webhook_events:
|
|
40
|
-
return
|
|
49
|
+
return "duplicate"
|
|
50
|
+
handler()
|
|
41
51
|
_processed_webhook_events.add(event_id)
|
|
42
|
-
return
|
|
52
|
+
return "processed"
|
|
@@ -6,6 +6,7 @@ export interface Order {
|
|
|
6
6
|
currency: "JPY" | "USD";
|
|
7
7
|
payment_attempt: number;
|
|
8
8
|
siglume_challenge_hash?: string;
|
|
9
|
+
siglume_checkout_url?: string;
|
|
9
10
|
siglume_checkout_session_id?: string;
|
|
10
11
|
siglume_requirement_id?: string;
|
|
11
12
|
siglume_chain_receipt_id?: string;
|
|
@@ -31,6 +32,15 @@ export function getOrder(orderId: string): Order | undefined {
|
|
|
31
32
|
return orders.get(orderId);
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
export function beginCheckoutAttempt(orderId: string): Order | undefined {
|
|
36
|
+
const order = orders.get(orderId);
|
|
37
|
+
if (!order) return undefined;
|
|
38
|
+
if (!order.payment_attempt) {
|
|
39
|
+
order.payment_attempt = 1;
|
|
40
|
+
}
|
|
41
|
+
return order;
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
export function allOrders(): Order[] {
|
|
35
45
|
return [...orders.values()];
|
|
36
46
|
}
|
|
@@ -43,10 +53,11 @@ export function findOrderByChallengeHash(challengeHash: string): Order | undefin
|
|
|
43
53
|
return [...orders.values()].find((order) => order.siglume_challenge_hash === challengeHash);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
|
-
export function
|
|
56
|
+
export async function processWebhookEventOnce(eventId: string, handler: () => Promise<void>): Promise<"processed" | "duplicate"> {
|
|
47
57
|
if (processedWebhookEvents.has(eventId)) {
|
|
48
|
-
return
|
|
58
|
+
return "duplicate";
|
|
49
59
|
}
|
|
60
|
+
await handler();
|
|
50
61
|
processedWebhookEvents.add(eventId);
|
|
51
|
-
return
|
|
62
|
+
return "processed";
|
|
52
63
|
}
|
|
@@ -10,9 +10,9 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
allOrders,
|
|
13
|
+
beginCheckoutAttempt,
|
|
13
14
|
findOrderByChallengeHash,
|
|
14
|
-
|
|
15
|
-
markWebhookEventProcessedOnce,
|
|
15
|
+
processWebhookEventOnce,
|
|
16
16
|
saveOrder,
|
|
17
17
|
} from "./order-store.js";
|
|
18
18
|
|
|
@@ -40,13 +40,23 @@ app.post(
|
|
|
40
40
|
express.json(),
|
|
41
41
|
asyncRoute(async (req, res) => {
|
|
42
42
|
const orderId = String(req.body?.order_id || "");
|
|
43
|
-
const order =
|
|
43
|
+
const order = beginCheckoutAttempt(orderId);
|
|
44
44
|
if (!order) {
|
|
45
45
|
res.status(404).json({ error: "order_not_found" });
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
order.
|
|
49
|
+
if (order.siglume_checkout_url && order.siglume_checkout_session_id) {
|
|
50
|
+
res.json({
|
|
51
|
+
order_id: order.id,
|
|
52
|
+
amount_minor: order.amount_minor,
|
|
53
|
+
currency: order.currency,
|
|
54
|
+
checkout_url: order.siglume_checkout_url,
|
|
55
|
+
session_id: order.siglume_checkout_session_id,
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
const session = await siglumeMerchant.createCheckoutSession({
|
|
51
61
|
merchant: merchantKey,
|
|
52
62
|
amount_minor: order.amount_minor,
|
|
@@ -58,6 +68,7 @@ app.post(
|
|
|
58
68
|
});
|
|
59
69
|
|
|
60
70
|
order.siglume_challenge_hash = session.challenge_hash;
|
|
71
|
+
order.siglume_checkout_url = session.checkout_url;
|
|
61
72
|
order.siglume_checkout_session_id = session.session_id;
|
|
62
73
|
order.siglume_payment_status = "pending";
|
|
63
74
|
saveOrder(order);
|
|
@@ -82,41 +93,42 @@ app.post(
|
|
|
82
93
|
req.header("siglume-signature") || "",
|
|
83
94
|
);
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
96
|
+
const result = await processWebhookEventOnce(event.id, async () => {
|
|
97
|
+
if (event.type === "direct_payment.confirmed") {
|
|
98
|
+
const confirmation = classifyDirectPaymentConfirmation(event);
|
|
99
|
+
|
|
100
|
+
if (confirmation.kind === "standard_settled") {
|
|
101
|
+
const order = findOrderByChallengeHash(confirmation.challenge_hash);
|
|
102
|
+
if (order) {
|
|
103
|
+
order.siglume_payment_status = "paid";
|
|
104
|
+
order.siglume_requirement_id = confirmation.requirement_id;
|
|
105
|
+
order.siglume_chain_receipt_id = confirmation.chain_receipt_id;
|
|
106
|
+
saveOrder(order);
|
|
107
|
+
}
|
|
108
|
+
} else if (confirmation.kind === "metered_usage_accepted") {
|
|
109
|
+
console.warn("Micro/Nano settlement integration is required before automatic fulfillment", {
|
|
110
|
+
event_id: event.id,
|
|
111
|
+
requirement_id: confirmation.requirement_id,
|
|
112
|
+
pricing_band: confirmation.pricing_band,
|
|
113
|
+
});
|
|
114
|
+
} else if (confirmation.kind === "metered_batch_settled") {
|
|
115
|
+
console.info("metered batch settled", {
|
|
116
|
+
settlement_batch_id: confirmation.settlement_batch_id,
|
|
117
|
+
chain_receipt_id: confirmation.chain_receipt_id,
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
console.warn("manual payment review required", {
|
|
121
|
+
event_id: event.id,
|
|
122
|
+
reason: confirmation.reason,
|
|
123
|
+
requirement_id: confirmation.requirement_id,
|
|
124
|
+
});
|
|
107
125
|
}
|
|
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
126
|
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (result === "duplicate") {
|
|
130
|
+
res.status(204).send();
|
|
131
|
+
return;
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
res.status(204).send();
|
package/package.json
CHANGED
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
# Express Integration Files
|
|
2
2
|
|
|
3
|
-
Mount the
|
|
3
|
+
Mount the webhook before any global JSON parser. Webhook signature verification
|
|
4
|
+
needs the raw request body; `express.json()` cannot recreate it after parsing.
|
|
4
5
|
|
|
5
6
|
```ts
|
|
6
|
-
import
|
|
7
|
+
import express from "express";
|
|
8
|
+
import {
|
|
9
|
+
createSiglumeSdrpCheckoutRouter,
|
|
10
|
+
createSiglumeSdrpWebhookHandler,
|
|
11
|
+
type SiglumeSdrpRouterOptions,
|
|
12
|
+
} from "./siglume/siglume-sdrp-routes.js";
|
|
7
13
|
import { siglumeOrderStore } from "./siglume/siglume-order-store.example.js";
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
const siglumeOptions: SiglumeSdrpRouterOptions = {
|
|
10
16
|
merchant: process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT!,
|
|
11
17
|
merchant_auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN!,
|
|
12
18
|
webhook_secret: process.env.SIGLUME_WEBHOOK_SECRET!,
|
|
13
19
|
shop_public_origin: process.env.SHOP_PUBLIC_ORIGIN!,
|
|
14
20
|
order_store: siglumeOrderStore,
|
|
15
|
-
|
|
21
|
+
allow_metered_payments: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
app.post(
|
|
25
|
+
"/payments/webhooks/siglume",
|
|
26
|
+
express.raw({ type: "application/json" }),
|
|
27
|
+
createSiglumeSdrpWebhookHandler(siglumeOptions),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
app.use(express.json());
|
|
31
|
+
app.use("/payments", createSiglumeSdrpCheckoutRouter(siglumeOptions));
|
|
16
32
|
```
|
|
17
33
|
|
|
18
34
|
Replace `siglume-order-store.example.ts` with your real order database adapter.
|
|
35
|
+
Keep `processWebhookEventOnce()` transactional: record the webhook event as
|
|
36
|
+
processed only after the order update or review write succeeds. The generated
|
|
37
|
+
route defaults to Standard-only. Enable `allow_metered_payments` only after you
|
|
38
|
+
implement Micro / Nano settlement reconciliation and past-due handling.
|
|
19
39
|
The route paths become:
|
|
20
40
|
|
|
21
41
|
- `POST /payments/checkout/siglume/start`
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { Request } from "express";
|
|
2
2
|
|
|
3
|
-
import type { SiglumeCheckoutOrder, SiglumeSdrpOrderStore } from "./siglume-sdrp-routes.js";
|
|
3
|
+
import type { SiglumeCheckoutAttempt, SiglumeCheckoutOrder, SiglumeSdrpOrderStore } from "./siglume-sdrp-routes.js";
|
|
4
4
|
|
|
5
5
|
type Order = SiglumeCheckoutOrder & {
|
|
6
6
|
status: "created" | "pending" | "paid" | "fulfilled_unsettled" | "review_required";
|
|
7
|
+
attempt_id?: string;
|
|
8
|
+
stable_nonce?: string;
|
|
7
9
|
challenge_hash?: string;
|
|
10
|
+
checkout_url?: string;
|
|
8
11
|
checkout_session_id?: string;
|
|
9
12
|
requirement_id?: string;
|
|
10
13
|
chain_receipt_id?: string;
|
|
@@ -16,20 +19,33 @@ const orders = new Map<string, Order>([
|
|
|
16
19
|
const processedEvents = new Set<string>();
|
|
17
20
|
|
|
18
21
|
export const siglumeOrderStore: SiglumeSdrpOrderStore = {
|
|
19
|
-
async
|
|
20
|
-
|
|
22
|
+
async beginCheckoutAttempt(orderId: string, _req: Request): Promise<SiglumeCheckoutAttempt | null> {
|
|
23
|
+
const order = orders.get(orderId);
|
|
24
|
+
if (!order) return null;
|
|
25
|
+
order.attempt_id ||= `${order.id}_attempt_1`;
|
|
26
|
+
order.stable_nonce ||= `${order.id}-attempt_1`;
|
|
27
|
+
return {
|
|
28
|
+
...order,
|
|
29
|
+
order_id: order.id,
|
|
30
|
+
attempt_id: order.attempt_id,
|
|
31
|
+
stable_nonce: order.stable_nonce,
|
|
32
|
+
};
|
|
21
33
|
},
|
|
22
34
|
async markCheckoutPending(input) {
|
|
23
35
|
const order = orders.get(input.order_id);
|
|
24
36
|
if (!order) return;
|
|
25
37
|
order.status = "pending";
|
|
38
|
+
order.attempt_id = input.attempt_id;
|
|
39
|
+
order.stable_nonce = input.stable_nonce;
|
|
26
40
|
order.challenge_hash = input.challenge_hash;
|
|
27
41
|
order.checkout_session_id = input.checkout_session_id;
|
|
42
|
+
order.checkout_url = input.checkout_url;
|
|
28
43
|
},
|
|
29
|
-
async
|
|
30
|
-
if (processedEvents.has(eventId)) return
|
|
44
|
+
async processWebhookEventOnce(eventId, handler) {
|
|
45
|
+
if (processedEvents.has(eventId)) return "duplicate";
|
|
46
|
+
await handler();
|
|
31
47
|
processedEvents.add(eventId);
|
|
32
|
-
return
|
|
48
|
+
return "processed";
|
|
33
49
|
},
|
|
34
50
|
async findOrderByChallengeHash(challengeHash) {
|
|
35
51
|
return [...orders.values()].find((order) => order.challenge_hash === challengeHash) || null;
|