@siglume/direct-request-payment 0.4.19 → 0.4.22
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 +50 -0
- package/README.md +18 -10
- package/bin/siglume-sdrp.mjs +550 -8
- package/dist/index.cjs +37 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/docs/announcement-ja.md +17 -3
- package/docs/api-reference.md +60 -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 +134 -24
- package/docs/sandbox.md +60 -0
- package/docs/troubleshooting.md +23 -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 +10 -2
- package/templates/express/README.md +40 -6
- package/templates/express/siglume-order-store.example.ts +22 -6
- package/templates/express/siglume-order-store.sql.ts +585 -0
- package/templates/express/siglume-sdrp-routes.ts +138 -64
- package/templates/fastapi/README.md +22 -3
- package/templates/fastapi/siglume_order_store_example.py +29 -6
- package/templates/fastapi/siglume_order_store_sqlalchemy.py +313 -0
- package/templates/fastapi/siglume_sdrp_routes.py +112 -49
package/docs/troubleshooting.md
CHANGED
|
@@ -11,19 +11,33 @@ Hosted Checkout is enabled account by account during beta. Check this before
|
|
|
11
11
|
building a human web checkout:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
+
npx siglume-check readiness --sandbox
|
|
14
15
|
npx siglume-check readiness
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Run `--sandbox` against the local SDK sandbox first. Then run the same command
|
|
19
|
+
without `--sandbox` against live credentials. The command validates local
|
|
20
|
+
configuration, reads the merchant account, checks the active billing mandate,
|
|
21
|
+
confirms the webhook subscription, creates one unpaid expiring checkout session,
|
|
22
|
+
and queues a signed webhook test delivery.
|
|
20
23
|
|
|
21
24
|
- The merchant account exists.
|
|
22
25
|
- The merchant billing mandate is active.
|
|
23
|
-
-
|
|
26
|
+
- `SIGLUME_WEBHOOK_SECRET` is present and matches the subscription secret hint.
|
|
27
|
+
- The webhook callback URL is HTTPS and matches an active subscription.
|
|
28
|
+
- The subscription includes `direct_payment.confirmed`.
|
|
24
29
|
- The checkout return URL origins are registered through
|
|
25
30
|
`checkout_allowed_origins`.
|
|
26
31
|
- The account has Hosted Checkout enabled.
|
|
32
|
+
- The signed webhook test delivery reaches the endpoint and returns success.
|
|
33
|
+
|
|
34
|
+
`--no-api` is only for local config smoke tests. `--no-probe` is a partial API
|
|
35
|
+
check and does not report readiness as ready.
|
|
36
|
+
|
|
37
|
+
If sandbox readiness fails, make sure `SIGLUME_ENV=sandbox`,
|
|
38
|
+
`SIGLUME_API_BASE=http://127.0.0.1:8787/v1`, `SHOP_PUBLIC_ORIGIN`, and
|
|
39
|
+
`SHOP_WEBHOOK_URL` all point to your local product, and that
|
|
40
|
+
`siglume-sdrp sandbox --webhook-url ...` is still running.
|
|
27
41
|
|
|
28
42
|
If `createCheckoutSession(...)` or `getCheckoutSession(...)` raises
|
|
29
43
|
`HostedCheckoutNotAvailableError`, do not show the raw 404/409 to the buyer.
|
|
@@ -46,10 +60,11 @@ contact for Hosted Checkout enablement.
|
|
|
46
60
|
|
|
47
61
|
- Verify the exact raw request body bytes or raw body string.
|
|
48
62
|
- Do not verify a parsed JSON object or a re-stringified JSON body.
|
|
49
|
-
- Return a 2xx only after
|
|
50
|
-
|
|
51
|
-
- Store processed webhook event ids or settlement identifiers durably
|
|
52
|
-
|
|
63
|
+
- Return a 2xx only after the order update or durable manual-review write has
|
|
64
|
+
succeeded, or after you safely decided the event is duplicate/ignored.
|
|
65
|
+
- Store processed webhook event ids or settlement identifiers durably, in the
|
|
66
|
+
same database transaction as the order update/review write. An in-memory set
|
|
67
|
+
is not enough for production.
|
|
53
68
|
- Do not assume delivery order. A settlement batch event may be reconciled from
|
|
54
69
|
statement APIs rather than from one order challenge.
|
|
55
70
|
- On signature failure, return a non-2xx status and do not mutate order state.
|
|
@@ -36,11 +36,21 @@ app.use((req, res, next) => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
const orders = new Map<string, any>();
|
|
39
|
+
const processedWebhookEvents = new Set<string>();
|
|
39
40
|
|
|
40
41
|
async function flagForPaymentStateReview(payload: Record<string, any>): Promise<void> {
|
|
41
42
|
console.warn("payment state review required", payload);
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
async function processWebhookEventOnce(eventId: string, handler: () => Promise<void>): Promise<"processed" | "duplicate"> {
|
|
46
|
+
if (processedWebhookEvents.has(eventId)) {
|
|
47
|
+
return "duplicate";
|
|
48
|
+
}
|
|
49
|
+
await handler();
|
|
50
|
+
processedWebhookEvents.add(eventId);
|
|
51
|
+
return "processed";
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
async function handleDirectPaymentConfirmed(event: any): Promise<void> {
|
|
45
55
|
const classification = classifyDirectPaymentConfirmation(event);
|
|
46
56
|
|
|
@@ -67,16 +77,11 @@ async function handleDirectPaymentConfirmed(event: any): Promise<void> {
|
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
if (classification.kind === "metered_usage_accepted") {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
await flagForPaymentStateReview({
|
|
76
|
-
reason: "unknown_metered_challenge_hash",
|
|
77
|
-
requirement_id: classification.requirement_id,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
+
await flagForPaymentStateReview({
|
|
81
|
+
reason: "metered_integration_required",
|
|
82
|
+
requirement_id: classification.requirement_id,
|
|
83
|
+
pricing_band: classification.pricing_band,
|
|
84
|
+
});
|
|
80
85
|
return;
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -102,7 +107,19 @@ app.post("/checkout/siglume/start", asyncRoute(async (req, res) => {
|
|
|
102
107
|
return;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
|
|
110
|
+
if (!Number(order.payment_attempt || 0)) {
|
|
111
|
+
order.payment_attempt = 1;
|
|
112
|
+
}
|
|
113
|
+
if (order.siglume_checkout_url && order.siglume_checkout_session_id) {
|
|
114
|
+
res.json({
|
|
115
|
+
order_id: order.id,
|
|
116
|
+
amount_minor: order.amount_minor,
|
|
117
|
+
currency: order.currency,
|
|
118
|
+
checkout_url: order.siglume_checkout_url,
|
|
119
|
+
session_id: order.siglume_checkout_session_id,
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
106
123
|
const session = await siglumeMerchant.createCheckoutSession({
|
|
107
124
|
merchant: merchantKey,
|
|
108
125
|
amount_minor: order.amount_minor,
|
|
@@ -114,6 +131,7 @@ app.post("/checkout/siglume/start", asyncRoute(async (req, res) => {
|
|
|
114
131
|
});
|
|
115
132
|
|
|
116
133
|
order.siglume_challenge_hash = session.challenge_hash;
|
|
134
|
+
order.siglume_checkout_url = session.checkout_url;
|
|
117
135
|
order.siglume_checkout_session_id = session.session_id;
|
|
118
136
|
order.siglume_payment_status = "pending";
|
|
119
137
|
|
|
@@ -134,8 +152,14 @@ app.post("/siglume/webhook", express.raw({ type: "application/json" }), asyncRou
|
|
|
134
152
|
header,
|
|
135
153
|
);
|
|
136
154
|
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
const result = await processWebhookEventOnce(event.id, async () => {
|
|
156
|
+
if (event.type === "direct_payment.confirmed") {
|
|
157
|
+
await handleDirectPaymentConfirmed(event);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
if (result === "duplicate") {
|
|
161
|
+
res.status(204).send();
|
|
162
|
+
return;
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
res.status(204).send();
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siglume/direct-request-payment",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.22",
|
|
4
4
|
"description": "SDK for the Siglume Direct Request Payment SDRP payment protocol",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"siglume",
|
|
@@ -79,9 +79,17 @@
|
|
|
79
79
|
"pack:check": "npm pack --json"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
+
"@types/express": "^5.0.6",
|
|
82
83
|
"@types/node": "^20.16.5",
|
|
84
|
+
"@types/sql.js": "^1.4.11",
|
|
85
|
+
"esbuild": "^0.28.1",
|
|
86
|
+
"express": "^5.2.1",
|
|
87
|
+
"sql.js": "^1.14.1",
|
|
83
88
|
"tsup": "^8.3.0",
|
|
84
89
|
"typescript": "^5.6.3",
|
|
85
|
-
"vitest": "^
|
|
90
|
+
"vitest": "^4.1.9"
|
|
91
|
+
},
|
|
92
|
+
"overrides": {
|
|
93
|
+
"esbuild": "^0.28.1"
|
|
86
94
|
}
|
|
87
95
|
}
|
|
@@ -1,21 +1,55 @@
|
|
|
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 {
|
|
7
|
+
import express from "express";
|
|
8
|
+
import {
|
|
9
|
+
createSiglumeSdrpCheckoutRouter,
|
|
10
|
+
createSiglumeSdrpWebhookHandler,
|
|
11
|
+
type SiglumeSdrpRouterOptions,
|
|
12
|
+
} from "./siglume/siglume-sdrp-routes.js";
|
|
13
|
+
import { createPrismaSiglumeOrderStore } from "./siglume/siglume-order-store.sql.js";
|
|
14
|
+
import { prisma } from "../db/prisma.js";
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
const siglumeOrderStore = createPrismaSiglumeOrderStore(prisma, {
|
|
17
|
+
dialect: "postgres",
|
|
18
|
+
orders_table: "orders",
|
|
19
|
+
order_id_column: "id",
|
|
20
|
+
amount_minor_column: "amount_minor",
|
|
21
|
+
currency_column: "currency",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const siglumeOptions: SiglumeSdrpRouterOptions = {
|
|
10
25
|
merchant: process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT!,
|
|
11
26
|
merchant_auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN!,
|
|
12
27
|
webhook_secret: process.env.SIGLUME_WEBHOOK_SECRET!,
|
|
13
28
|
shop_public_origin: process.env.SHOP_PUBLIC_ORIGIN!,
|
|
14
29
|
order_store: siglumeOrderStore,
|
|
15
|
-
|
|
30
|
+
allow_metered_payments: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
app.post(
|
|
34
|
+
"/payments/webhooks/siglume",
|
|
35
|
+
express.raw({ type: "application/json" }),
|
|
36
|
+
createSiglumeSdrpWebhookHandler(siglumeOptions),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
app.use(express.json());
|
|
40
|
+
app.use("/payments", createSiglumeSdrpCheckoutRouter(siglumeOptions));
|
|
16
41
|
```
|
|
17
42
|
|
|
18
|
-
|
|
43
|
+
Use `siglume-order-store.sql.ts` for a durable database-backed adapter. It
|
|
44
|
+
supports Prisma, TypeORM, Sequelize, Drizzle, and any driver that can implement
|
|
45
|
+
the small `SiglumeSqlExecutor` interface. Run
|
|
46
|
+
`createSiglumeSdrpSqlSchema({ dialect: "postgres" })` once in a migration or
|
|
47
|
+
translate the returned SQL into your migration tool.
|
|
48
|
+
|
|
49
|
+
Keep `processWebhookEventOnce()` transactional: record the webhook event as
|
|
50
|
+
processed only after the order update or review write succeeds. The generated
|
|
51
|
+
route defaults to Standard-only. Enable `allow_metered_payments` only after you
|
|
52
|
+
implement Micro / Nano settlement reconciliation and past-due handling.
|
|
19
53
|
The route paths become:
|
|
20
54
|
|
|
21
55
|
- `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;
|