@siglume/direct-request-payment 0.4.18 → 0.4.19
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 +19 -0
- package/README.md +22 -8
- package/bin/siglume-sdrp.mjs +267 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/docs/merchant-quickstart.md +5 -6
- package/docs/pricing.md +1 -1
- package/docs/quickstart-10-minutes.md +98 -129
- package/docs/troubleshooting.md +8 -0
- package/examples/hosted-checkout-python/pyproject.toml +1 -1
- package/package.json +9 -1
- package/templates/express/README.md +22 -0
- package/templates/express/siglume-order-store.example.ts +53 -0
- package/templates/express/siglume-sdrp-routes.ts +157 -0
- package/templates/fastapi/README.md +22 -0
- package/templates/fastapi/siglume_order_store_example.py +54 -0
- package/templates/fastapi/siglume_sdrp_routes.py +107 -0
|
@@ -1,176 +1,145 @@
|
|
|
1
|
-
# 10-Minute
|
|
1
|
+
# 10-Minute Product Integration
|
|
2
2
|
|
|
3
|
-
This guide is the
|
|
4
|
-
|
|
3
|
+
This guide is the supported 10-minute path for adding SDRP Hosted Checkout to
|
|
4
|
+
an existing product. The goal is to
|
|
5
|
+
add two routes to your own server:
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
- `POST /payments/checkout/siglume/start`
|
|
8
|
+
- `POST /payments/webhooks/siglume`
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
The SDK supplies the readiness check, route files, webhook verification, payment
|
|
11
|
+
classification, and the order-store adapter contract. Your app supplies the
|
|
12
|
+
real order lookup and fulfillment writes.
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
- one Hosted Checkout session,
|
|
13
|
-
- one signed `direct_payment.confirmed` webhook,
|
|
14
|
-
- one idempotent local fulfillment decision.
|
|
14
|
+
## 0. Readiness first
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
scheduled autopay, game entitlement recovery, or Micro / Nano accounting.
|
|
16
|
+
Install the SDK in your product.
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
Node / Express:
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
```bash
|
|
21
|
+
npm install @siglume/direct-request-payment
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Python / FastAPI:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install siglume-direct-request-payment
|
|
28
|
+
```
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
- Hosted Checkout is enabled for that merchant account.
|
|
25
|
-
- The merchant billing mandate is active, including any required wallet
|
|
26
|
-
approval.
|
|
27
|
-
- You have a public HTTPS webhook URL that can receive the raw request body.
|
|
28
|
-
- Your checkout return URL origin is known and can be registered.
|
|
29
|
-
- The buyer has a Siglume wallet funded in the settlement token for the test
|
|
30
|
-
market: JPYC for JPY, USDC for USD.
|
|
31
|
-
- Your order amount is in the Standard band: JPY 501+ or USD 3.01+.
|
|
30
|
+
Set these environment variables in your app or `.env`:
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
```bash
|
|
33
|
+
SIGLUME_MERCHANT_AUTH_TOKEN=<merchant Siglume bearer token>
|
|
34
|
+
SIGLUME_DIRECT_PAYMENT_MERCHANT=<merchant key>
|
|
35
|
+
SHOP_PUBLIC_ORIGIN=https://www.your-product.example
|
|
36
|
+
SHOP_WEBHOOK_URL=https://api.your-product.example/payments/webhooks/siglume
|
|
37
|
+
```
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
Then run:
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
```bash
|
|
42
|
+
npx siglume-check readiness
|
|
43
|
+
```
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
The readiness check fails before you write checkout code if any required item is
|
|
46
|
+
missing. It checks local config, reads the merchant account, and creates one
|
|
47
|
+
unpaid expiring Hosted Checkout probe session to prove the account is enabled
|
|
48
|
+
and the return origin is accepted. No buyer is charged.
|
|
44
49
|
|
|
45
|
-
For
|
|
50
|
+
For CI or a preflight script:
|
|
46
51
|
|
|
47
52
|
```bash
|
|
48
|
-
|
|
53
|
+
npx siglume-check readiness --json
|
|
49
54
|
```
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
If this command fails, fix the reported item first. Do not build a human web
|
|
57
|
+
checkout path until readiness passes.
|
|
58
|
+
|
|
59
|
+
## 1. Copy integration files into your product
|
|
60
|
+
|
|
61
|
+
For Express:
|
|
52
62
|
|
|
53
63
|
```bash
|
|
54
|
-
|
|
64
|
+
npx siglume-sdrp init express --target src/siglume
|
|
55
65
|
```
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
For FastAPI:
|
|
58
68
|
|
|
59
69
|
```bash
|
|
60
|
-
|
|
61
|
-
SIGLUME_DIRECT_PAYMENT_MERCHANT=example_merchant
|
|
62
|
-
SHOP_PUBLIC_ORIGIN=https://www.example.com
|
|
63
|
-
SHOP_WEBHOOK_URL=https://api.example.com/siglume/webhook
|
|
70
|
+
siglume-sdrp init fastapi --target app/siglume
|
|
64
71
|
```
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
These commands copy framework-specific route files into your codebase. The
|
|
74
|
+
generated files are intentionally small and are meant to be edited.
|
|
68
75
|
|
|
69
|
-
##
|
|
76
|
+
## 2. Mount the routes
|
|
70
77
|
|
|
71
|
-
|
|
78
|
+
Express:
|
|
72
79
|
|
|
73
80
|
```ts
|
|
74
|
-
import {
|
|
81
|
+
import { createSiglumeSdrpRouter } from "./siglume/siglume-sdrp-routes.js";
|
|
82
|
+
import { siglumeOrderStore } from "./siglume/siglume-order-store.example.js";
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN!,
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const setup = await merchant.setupCheckout({
|
|
84
|
+
app.use("/payments", createSiglumeSdrpRouter({
|
|
81
85
|
merchant: process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT!,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
console.log(setup.env.SIGLUME_DIRECT_PAYMENT_MERCHANT);
|
|
86
|
+
merchant_auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN!,
|
|
87
|
+
webhook_secret: process.env.SIGLUME_WEBHOOK_SECRET!,
|
|
88
|
+
shop_public_origin: process.env.SHOP_PUBLIC_ORIGIN!,
|
|
89
|
+
order_store: siglumeOrderStore,
|
|
90
|
+
}));
|
|
90
91
|
```
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
`SIGLUME_WEBHOOK_SECRET` in a server-side secret store. Secret values are
|
|
94
|
-
returned only when created or rotated.
|
|
95
|
-
|
|
96
|
-
## 4. Create a Standard checkout session
|
|
93
|
+
FastAPI:
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
```py
|
|
96
|
+
from .siglume.siglume_order_store_example import ExampleSiglumeOrderStore
|
|
97
|
+
from .siglume.siglume_sdrp_routes import create_siglume_sdrp_router
|
|
100
98
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
currency: "JPY",
|
|
106
|
-
nonce: "order_123-attempt_1",
|
|
107
|
-
success_url: `${process.env.SHOP_PUBLIC_ORIGIN}/thanks`,
|
|
108
|
-
cancel_url: `${process.env.SHOP_PUBLIC_ORIGIN}/cart`,
|
|
109
|
-
metadata: { order_id: "order_123" },
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
await orders.update("order_123", {
|
|
113
|
-
siglume_challenge_hash: session.challenge_hash,
|
|
114
|
-
siglume_checkout_session_id: session.session_id,
|
|
115
|
-
siglume_payment_status: "pending",
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
redirect(session.checkout_url);
|
|
99
|
+
app.include_router(
|
|
100
|
+
create_siglume_sdrp_router(ExampleSiglumeOrderStore()),
|
|
101
|
+
prefix="/payments",
|
|
102
|
+
)
|
|
119
103
|
```
|
|
120
104
|
|
|
121
|
-
|
|
122
|
-
session is single-use and expires.
|
|
105
|
+
## 3. Replace the order-store example
|
|
123
106
|
|
|
124
|
-
|
|
107
|
+
Replace the example store with your product's order database. The adapter must:
|
|
125
108
|
|
|
126
|
-
|
|
127
|
-
|
|
109
|
+
- load the order by your `order_id`,
|
|
110
|
+
- verify the current user is allowed to pay for that order,
|
|
111
|
+
- return the server-authored `amount_minor` and `currency`,
|
|
112
|
+
- persist `challenge_hash` and `checkout_session_id` before redirecting,
|
|
113
|
+
- record webhook event ids durably,
|
|
114
|
+
- mark Standard orders paid exactly once,
|
|
115
|
+
- mark Micro / Nano orders as fulfilled but unsettled exactly once,
|
|
116
|
+
- route unknown classifications to manual review.
|
|
128
117
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
if (event.type === "direct_payment.confirmed") {
|
|
142
|
-
const confirmation = classifyDirectPaymentConfirmation(event);
|
|
143
|
-
|
|
144
|
-
if (confirmation.kind === "standard_settled") {
|
|
145
|
-
await orders.markPaidOnceByChallengeHash(confirmation.challenge_hash, {
|
|
146
|
-
requirement_id: confirmation.requirement_id,
|
|
147
|
-
chain_receipt_id: confirmation.chain_receipt_id,
|
|
148
|
-
});
|
|
149
|
-
} else if (confirmation.kind === "metered_usage_accepted") {
|
|
150
|
-
await orders.markFulfilledButUnsettledOnceByChallengeHash(
|
|
151
|
-
confirmation.challenge_hash,
|
|
152
|
-
{ requirement_id: confirmation.requirement_id },
|
|
153
|
-
);
|
|
154
|
-
} else {
|
|
155
|
-
await orders.flagForPaymentStateReview(confirmation);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
118
|
+
Do not calculate the amount from browser input.
|
|
119
|
+
|
|
120
|
+
## 4. Start checkout from your frontend
|
|
121
|
+
|
|
122
|
+
Call your own server route:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
curl -X POST https://api.your-product.example/payments/checkout/siglume/start \
|
|
126
|
+
-H "content-type: application/json" \
|
|
127
|
+
-d "{\"order_id\":\"order_123\"}"
|
|
158
128
|
```
|
|
159
129
|
|
|
160
|
-
|
|
161
|
-
expected successful branch is `standard_settled`.
|
|
130
|
+
Redirect the shopper to the returned `checkout_url`.
|
|
162
131
|
|
|
163
|
-
## Done means
|
|
132
|
+
## 5. Done means
|
|
164
133
|
|
|
165
|
-
|
|
134
|
+
Your product is integrated when:
|
|
166
135
|
|
|
167
|
-
-
|
|
168
|
-
-
|
|
136
|
+
- `npx siglume-check readiness` passes,
|
|
137
|
+
- your product has mounted checkout and webhook routes,
|
|
138
|
+
- your order database stores `challenge_hash` for the order,
|
|
169
139
|
- the signed webhook verifies against the raw body,
|
|
170
|
-
- `
|
|
171
|
-
-
|
|
140
|
+
- `standard_settled` marks the order paid once,
|
|
141
|
+
- `metered_usage_accepted` uses a separate fulfilled-but-unsettled state.
|
|
172
142
|
|
|
173
|
-
|
|
174
|
-
[
|
|
175
|
-
[
|
|
176
|
-
[Troubleshooting](./troubleshooting.md).
|
|
143
|
+
For Micro / Nano revenue reconciliation, read
|
|
144
|
+
[Payment lifecycle](./payment-lifecycle.md) and
|
|
145
|
+
[Micro / Nano Statements and Notices](./metered-statements.md).
|
package/docs/troubleshooting.md
CHANGED
|
@@ -10,6 +10,14 @@ Siglume account contact.
|
|
|
10
10
|
Hosted Checkout is enabled account by account during beta. Check this before
|
|
11
11
|
building a human web checkout:
|
|
12
12
|
|
|
13
|
+
```bash
|
|
14
|
+
npx siglume-check readiness
|
|
15
|
+
```
|
|
16
|
+
|
|
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.
|
|
20
|
+
|
|
13
21
|
- The merchant account exists.
|
|
14
22
|
- The merchant billing mandate is active.
|
|
15
23
|
- The webhook callback URL is HTTPS and reachable.
|
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.19",
|
|
4
4
|
"description": "SDK for the Siglume Direct Request Payment SDRP payment protocol",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"siglume",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"main": "./dist/index.cjs",
|
|
34
34
|
"module": "./dist/index.js",
|
|
35
35
|
"types": "./dist/index.d.ts",
|
|
36
|
+
"bin": {
|
|
37
|
+
"siglume-sdrp": "./bin/siglume-sdrp.mjs",
|
|
38
|
+
"siglume-check": "./bin/siglume-sdrp.mjs"
|
|
39
|
+
},
|
|
36
40
|
"exports": {
|
|
37
41
|
".": {
|
|
38
42
|
"types": "./dist/index.d.ts",
|
|
@@ -52,11 +56,15 @@
|
|
|
52
56
|
},
|
|
53
57
|
"files": [
|
|
54
58
|
"dist",
|
|
59
|
+
"bin",
|
|
55
60
|
"docs",
|
|
56
61
|
"examples",
|
|
62
|
+
"templates",
|
|
57
63
|
"!examples/**/node_modules",
|
|
58
64
|
"!examples/**/__pycache__",
|
|
59
65
|
"!examples/**/*.pyc",
|
|
66
|
+
"!templates/**/__pycache__",
|
|
67
|
+
"!templates/**/*.pyc",
|
|
60
68
|
"README.md",
|
|
61
69
|
"CHANGELOG.md",
|
|
62
70
|
"LICENSE"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Express Integration Files
|
|
2
|
+
|
|
3
|
+
Mount the router in your existing app:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createSiglumeSdrpRouter } from "./siglume/siglume-sdrp-routes.js";
|
|
7
|
+
import { siglumeOrderStore } from "./siglume/siglume-order-store.example.js";
|
|
8
|
+
|
|
9
|
+
app.use("/payments", createSiglumeSdrpRouter({
|
|
10
|
+
merchant: process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT!,
|
|
11
|
+
merchant_auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN!,
|
|
12
|
+
webhook_secret: process.env.SIGLUME_WEBHOOK_SECRET!,
|
|
13
|
+
shop_public_origin: process.env.SHOP_PUBLIC_ORIGIN!,
|
|
14
|
+
order_store: siglumeOrderStore,
|
|
15
|
+
}));
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Replace `siglume-order-store.example.ts` with your real order database adapter.
|
|
19
|
+
The route paths become:
|
|
20
|
+
|
|
21
|
+
- `POST /payments/checkout/siglume/start`
|
|
22
|
+
- `POST /payments/webhooks/siglume`
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Request } from "express";
|
|
2
|
+
|
|
3
|
+
import type { SiglumeCheckoutOrder, SiglumeSdrpOrderStore } from "./siglume-sdrp-routes.js";
|
|
4
|
+
|
|
5
|
+
type Order = SiglumeCheckoutOrder & {
|
|
6
|
+
status: "created" | "pending" | "paid" | "fulfilled_unsettled" | "review_required";
|
|
7
|
+
challenge_hash?: string;
|
|
8
|
+
checkout_session_id?: string;
|
|
9
|
+
requirement_id?: string;
|
|
10
|
+
chain_receipt_id?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const orders = new Map<string, Order>([
|
|
14
|
+
["order_123", { id: "order_123", amount_minor: 1200, currency: "JPY", status: "created" }],
|
|
15
|
+
]);
|
|
16
|
+
const processedEvents = new Set<string>();
|
|
17
|
+
|
|
18
|
+
export const siglumeOrderStore: SiglumeSdrpOrderStore = {
|
|
19
|
+
async getOrderForCheckout(orderId: string, _req: Request) {
|
|
20
|
+
return orders.get(orderId) || null;
|
|
21
|
+
},
|
|
22
|
+
async markCheckoutPending(input) {
|
|
23
|
+
const order = orders.get(input.order_id);
|
|
24
|
+
if (!order) return;
|
|
25
|
+
order.status = "pending";
|
|
26
|
+
order.challenge_hash = input.challenge_hash;
|
|
27
|
+
order.checkout_session_id = input.checkout_session_id;
|
|
28
|
+
},
|
|
29
|
+
async recordWebhookEventOnce(eventId) {
|
|
30
|
+
if (processedEvents.has(eventId)) return false;
|
|
31
|
+
processedEvents.add(eventId);
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
async findOrderByChallengeHash(challengeHash) {
|
|
35
|
+
return [...orders.values()].find((order) => order.challenge_hash === challengeHash) || null;
|
|
36
|
+
},
|
|
37
|
+
async markOrderPaidOnce(input) {
|
|
38
|
+
const order = orders.get(input.order_id);
|
|
39
|
+
if (!order || order.status === "paid") return;
|
|
40
|
+
order.status = "paid";
|
|
41
|
+
order.requirement_id = input.requirement_id;
|
|
42
|
+
order.chain_receipt_id = input.chain_receipt_id;
|
|
43
|
+
},
|
|
44
|
+
async markOrderFulfilledUnsettledOnce(input) {
|
|
45
|
+
const order = orders.get(input.order_id);
|
|
46
|
+
if (!order || order.status === "fulfilled_unsettled") return;
|
|
47
|
+
order.status = "fulfilled_unsettled";
|
|
48
|
+
order.requirement_id = input.requirement_id;
|
|
49
|
+
},
|
|
50
|
+
async flagPaymentReview(input) {
|
|
51
|
+
console.warn("payment review required", input);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import {
|
|
3
|
+
classifyDirectPaymentConfirmation,
|
|
4
|
+
DirectRequestPaymentMerchantClient,
|
|
5
|
+
HostedCheckoutNotAvailableError,
|
|
6
|
+
verifyDirectRequestPaymentWebhook,
|
|
7
|
+
type DirectRequestPaymentCurrency,
|
|
8
|
+
} from "@siglume/direct-request-payment";
|
|
9
|
+
|
|
10
|
+
export interface SiglumeCheckoutOrder {
|
|
11
|
+
id: string;
|
|
12
|
+
amount_minor: number;
|
|
13
|
+
currency: DirectRequestPaymentCurrency | string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SiglumeSdrpOrderStore {
|
|
17
|
+
getOrderForCheckout(orderId: string, req: express.Request): Promise<SiglumeCheckoutOrder | null>;
|
|
18
|
+
markCheckoutPending(input: {
|
|
19
|
+
order_id: string;
|
|
20
|
+
challenge_hash: string;
|
|
21
|
+
checkout_session_id: string;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
recordWebhookEventOnce(eventId: string): Promise<boolean>;
|
|
24
|
+
findOrderByChallengeHash(challengeHash: string): Promise<{ id: string } | null>;
|
|
25
|
+
markOrderPaidOnce(input: {
|
|
26
|
+
order_id: string;
|
|
27
|
+
requirement_id: string;
|
|
28
|
+
chain_receipt_id: string;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
markOrderFulfilledUnsettledOnce(input: {
|
|
31
|
+
order_id: string;
|
|
32
|
+
requirement_id: string;
|
|
33
|
+
pricing_band: string;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
flagPaymentReview(input: Record<string, unknown>): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SiglumeSdrpRouterOptions {
|
|
39
|
+
merchant: string;
|
|
40
|
+
merchant_auth_token: string;
|
|
41
|
+
webhook_secret: string;
|
|
42
|
+
shop_public_origin: string;
|
|
43
|
+
order_store: SiglumeSdrpOrderStore;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createSiglumeSdrpRouter(options: SiglumeSdrpRouterOptions): express.Router {
|
|
47
|
+
const router = express.Router();
|
|
48
|
+
const merchant = new DirectRequestPaymentMerchantClient({
|
|
49
|
+
auth_token: options.merchant_auth_token,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
router.post("/checkout/siglume/start", express.json(), async (req, res, next) => {
|
|
53
|
+
try {
|
|
54
|
+
const orderId = String(req.body?.order_id || "");
|
|
55
|
+
const order = await options.order_store.getOrderForCheckout(orderId, req);
|
|
56
|
+
if (!order) {
|
|
57
|
+
res.status(404).json({ error: "order_not_found" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const session = await merchant.createCheckoutSession({
|
|
62
|
+
merchant: options.merchant,
|
|
63
|
+
amount_minor: order.amount_minor,
|
|
64
|
+
currency: order.currency,
|
|
65
|
+
nonce: `${order.id}-attempt_${Date.now()}`,
|
|
66
|
+
success_url: `${options.shop_public_origin}/checkout/siglume/success`,
|
|
67
|
+
cancel_url: `${options.shop_public_origin}/checkout/siglume/cancel`,
|
|
68
|
+
metadata: { order_id: order.id },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await options.order_store.markCheckoutPending({
|
|
72
|
+
order_id: order.id,
|
|
73
|
+
challenge_hash: session.challenge_hash,
|
|
74
|
+
checkout_session_id: session.session_id,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
res.json({ checkout_url: session.checkout_url, session_id: session.session_id });
|
|
78
|
+
} catch (error) {
|
|
79
|
+
next(error);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
router.post("/webhooks/siglume", express.raw({ type: "application/json" }), async (req, res, next) => {
|
|
84
|
+
try {
|
|
85
|
+
const { event } = await verifyDirectRequestPaymentWebhook(
|
|
86
|
+
options.webhook_secret,
|
|
87
|
+
req.body,
|
|
88
|
+
req.header("siglume-signature") || "",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!(await options.order_store.recordWebhookEventOnce(event.id))) {
|
|
92
|
+
res.status(204).send();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (event.type === "direct_payment.confirmed") {
|
|
97
|
+
const confirmation = classifyDirectPaymentConfirmation(event);
|
|
98
|
+
|
|
99
|
+
if (confirmation.kind === "standard_settled") {
|
|
100
|
+
const order = await options.order_store.findOrderByChallengeHash(confirmation.challenge_hash);
|
|
101
|
+
if (order) {
|
|
102
|
+
await options.order_store.markOrderPaidOnce({
|
|
103
|
+
order_id: order.id,
|
|
104
|
+
requirement_id: confirmation.requirement_id,
|
|
105
|
+
chain_receipt_id: confirmation.chain_receipt_id,
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
await options.order_store.flagPaymentReview({
|
|
109
|
+
reason: "unknown_challenge_hash",
|
|
110
|
+
requirement_id: confirmation.requirement_id,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} else if (confirmation.kind === "metered_usage_accepted") {
|
|
114
|
+
const order = await options.order_store.findOrderByChallengeHash(confirmation.challenge_hash);
|
|
115
|
+
if (order) {
|
|
116
|
+
await options.order_store.markOrderFulfilledUnsettledOnce({
|
|
117
|
+
order_id: order.id,
|
|
118
|
+
requirement_id: confirmation.requirement_id,
|
|
119
|
+
pricing_band: confirmation.pricing_band,
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
await options.order_store.flagPaymentReview({
|
|
123
|
+
reason: "unknown_metered_challenge_hash",
|
|
124
|
+
requirement_id: confirmation.requirement_id,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
} else if (confirmation.kind === "metered_batch_settled") {
|
|
128
|
+
await options.order_store.flagPaymentReview({
|
|
129
|
+
reason: "metered_batch_settled_reconcile_statement_api",
|
|
130
|
+
settlement_batch_id: confirmation.settlement_batch_id,
|
|
131
|
+
chain_receipt_id: confirmation.chain_receipt_id,
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
await options.order_store.flagPaymentReview({
|
|
135
|
+
reason: confirmation.reason,
|
|
136
|
+
requirement_id: confirmation.requirement_id,
|
|
137
|
+
settlement_batch_id: confirmation.settlement_batch_id,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
res.status(204).send();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
next(error);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
router.use((error: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
149
|
+
if (error instanceof HostedCheckoutNotAvailableError) {
|
|
150
|
+
res.status(409).json({ error: "hosted_checkout_not_enabled" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
next(error);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return router;
|
|
157
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# FastAPI Integration Files
|
|
2
|
+
|
|
3
|
+
Mount the router in your existing app:
|
|
4
|
+
|
|
5
|
+
```py
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
8
|
+
from .siglume.siglume_order_store_example import ExampleSiglumeOrderStore
|
|
9
|
+
from .siglume.siglume_sdrp_routes import create_siglume_sdrp_router
|
|
10
|
+
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
app.include_router(
|
|
13
|
+
create_siglume_sdrp_router(ExampleSiglumeOrderStore()),
|
|
14
|
+
prefix="/payments",
|
|
15
|
+
)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Replace `siglume_order_store_example.py` with your real order database adapter.
|
|
19
|
+
The route paths become:
|
|
20
|
+
|
|
21
|
+
- `POST /payments/checkout/siglume/start`
|
|
22
|
+
- `POST /payments/webhooks/siglume`
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
|
|
7
|
+
_orders: dict[str, dict[str, Any]] = {
|
|
8
|
+
"order_123": {"id": "order_123", "amount_minor": 1200, "currency": "JPY", "status": "created"}
|
|
9
|
+
}
|
|
10
|
+
_processed_events: set[str] = set()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExampleSiglumeOrderStore:
|
|
14
|
+
async def get_order_for_checkout(self, order_id: str, request: Request) -> dict[str, Any] | None:
|
|
15
|
+
return _orders.get(order_id)
|
|
16
|
+
|
|
17
|
+
async def mark_checkout_pending(self, *, order_id: str, challenge_hash: str, checkout_session_id: str) -> None:
|
|
18
|
+
order = _orders.get(order_id)
|
|
19
|
+
if not order:
|
|
20
|
+
return
|
|
21
|
+
order["status"] = "pending"
|
|
22
|
+
order["challenge_hash"] = challenge_hash
|
|
23
|
+
order["checkout_session_id"] = checkout_session_id
|
|
24
|
+
|
|
25
|
+
async def record_webhook_event_once(self, event_id: str) -> bool:
|
|
26
|
+
if event_id in _processed_events:
|
|
27
|
+
return False
|
|
28
|
+
_processed_events.add(event_id)
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None:
|
|
32
|
+
for order in _orders.values():
|
|
33
|
+
if order.get("challenge_hash") == challenge_hash:
|
|
34
|
+
return order
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
async def mark_order_paid_once(self, *, order_id: str, requirement_id: str, chain_receipt_id: str) -> None:
|
|
38
|
+
order = _orders.get(order_id)
|
|
39
|
+
if not order or order.get("status") == "paid":
|
|
40
|
+
return
|
|
41
|
+
order["status"] = "paid"
|
|
42
|
+
order["requirement_id"] = requirement_id
|
|
43
|
+
order["chain_receipt_id"] = chain_receipt_id
|
|
44
|
+
|
|
45
|
+
async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None:
|
|
46
|
+
order = _orders.get(order_id)
|
|
47
|
+
if not order or order.get("status") == "fulfilled_unsettled":
|
|
48
|
+
return
|
|
49
|
+
order["status"] = "fulfilled_unsettled"
|
|
50
|
+
order["requirement_id"] = requirement_id
|
|
51
|
+
order["pricing_band"] = pricing_band
|
|
52
|
+
|
|
53
|
+
async def flag_payment_review(self, data: dict[str, Any]) -> None:
|
|
54
|
+
print("payment review required", data)
|