@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +18 -10
  3. package/bin/siglume-sdrp.mjs +550 -8
  4. package/dist/index.cjs +37 -3
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -2
  7. package/dist/index.d.ts +27 -2
  8. package/dist/index.js +37 -3
  9. package/dist/index.js.map +1 -1
  10. package/docs/announcement-ja.md +17 -3
  11. package/docs/api-reference.md +60 -13
  12. package/docs/merchant-quickstart.md +6 -20
  13. package/docs/metered-statements.md +15 -13
  14. package/docs/payment-lifecycle.md +12 -9
  15. package/docs/pricing.md +7 -4
  16. package/docs/quickstart-10-minutes.md +134 -24
  17. package/docs/sandbox.md +60 -0
  18. package/docs/troubleshooting.md +23 -8
  19. package/examples/express-checkout.ts +37 -13
  20. package/examples/hosted-checkout-python/app.py +46 -31
  21. package/examples/hosted-checkout-python/order_store.py +13 -3
  22. package/examples/hosted-checkout-python/pyproject.toml +1 -1
  23. package/examples/hosted-checkout-typescript/src/order-store.ts +14 -3
  24. package/examples/hosted-checkout-typescript/src/server.ts +49 -37
  25. package/package.json +10 -2
  26. package/templates/express/README.md +40 -6
  27. package/templates/express/siglume-order-store.example.ts +22 -6
  28. package/templates/express/siglume-order-store.sql.ts +585 -0
  29. package/templates/express/siglume-sdrp-routes.ts +138 -64
  30. package/templates/fastapi/README.md +22 -3
  31. package/templates/fastapi/siglume_order_store_example.py +29 -6
  32. package/templates/fastapi/siglume_order_store_sqlalchemy.py +313 -0
  33. package/templates/fastapi/siglume_sdrp_routes.py +112 -49
@@ -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
- The command validates local configuration, reads the merchant account, and
18
- creates one unpaid expiring checkout session to prove Hosted Checkout is
19
- available for this merchant account.
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
- - The webhook callback URL is HTTPS and reachable.
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 you have durably recorded the event or safely decided
50
- it is duplicate/ignored.
51
- - Store processed webhook event ids or settlement identifiers durably; an
52
- in-memory set is not enough for production.
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
- const order = [...orders.values()].find((item) => item.siglume_challenge_hash === classification.challenge_hash);
71
- if (order) {
72
- order.siglume_payment_status = "fulfilled_unsettled";
73
- order.siglume_requirement_id = classification.requirement_id;
74
- } else {
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
- order.payment_attempt = Number(order.payment_attempt || 0) + 1;
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
- if (event.type === "direct_payment.confirmed") {
138
- await handleDirectPaymentConfirmed(event);
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
- 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.22,<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.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": "^2.1.8"
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 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 { siglumeOrderStore } from "./siglume/siglume-order-store.example.js";
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
- app.use("/payments", createSiglumeSdrpRouter({
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
- Replace `siglume-order-store.example.ts` with your real order database adapter.
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 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;