@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.
@@ -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
- get_order,
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 = get_order(order_id)
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["payment_attempt"] = int(order.get("payment_attempt") or 0) + 1
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
- if not mark_webhook_event_processed_once(str(event["id"])):
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 mark_webhook_event_processed_once(event_id: str) -> bool:
47
+ def process_webhook_event_once(event_id: str, handler) -> str:
39
48
  if event_id in _processed_webhook_events:
40
- return False
49
+ return "duplicate"
50
+ handler()
41
51
  _processed_webhook_events.add(event_id)
42
- return True
52
+ return "processed"
@@ -5,5 +5,5 @@ requires-python = ">=3.11"
5
5
  dependencies = [
6
6
  "Flask>=3.0,<4",
7
7
  "python-dotenv>=1.0,<2",
8
- "siglume-direct-request-payment>=0.4.19,<0.5",
8
+ "siglume-direct-request-payment>=0.4.20,<0.5",
9
9
  ]
@@ -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 markWebhookEventProcessedOnce(eventId: string): boolean {
56
+ export async function processWebhookEventOnce(eventId: string, handler: () => Promise<void>): Promise<"processed" | "duplicate"> {
47
57
  if (processedWebhookEvents.has(eventId)) {
48
- return false;
58
+ return "duplicate";
49
59
  }
60
+ await handler();
50
61
  processedWebhookEvents.add(eventId);
51
- return true;
62
+ return "processed";
52
63
  }
@@ -10,9 +10,9 @@ import {
10
10
 
11
11
  import {
12
12
  allOrders,
13
+ beginCheckoutAttempt,
13
14
  findOrderByChallengeHash,
14
- getOrder,
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 = getOrder(orderId);
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.payment_attempt += 1;
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
- 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);
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.19",
3
+ "version": "0.4.20",
4
4
  "description": "SDK for the Siglume Direct Request Payment SDRP payment protocol",
5
5
  "keywords": [
6
6
  "siglume",
@@ -1,21 +1,41 @@
1
1
  # Express Integration Files
2
2
 
3
- Mount the router in your existing app:
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 { createSiglumeSdrpRouter } from "./siglume/siglume-sdrp-routes.js";
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
- app.use("/payments", createSiglumeSdrpRouter({
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 getOrderForCheckout(orderId: string, _req: Request) {
20
- return orders.get(orderId) || null;
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 recordWebhookEventOnce(eventId) {
30
- if (processedEvents.has(eventId)) return false;
44
+ async processWebhookEventOnce(eventId, handler) {
45
+ if (processedEvents.has(eventId)) return "duplicate";
46
+ await handler();
31
47
  processedEvents.add(eventId);
32
- return true;
48
+ return "processed";
33
49
  },
34
50
  async findOrderByChallengeHash(challengeHash) {
35
51
  return [...orders.values()].find((order) => order.challenge_hash === challengeHash) || null;