@siglume/direct-request-payment 0.4.17 → 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 +40 -0
- package/README.md +60 -4
- package/bin/siglume-sdrp.mjs +267 -0
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/docs/announcement-ja.md +5 -3
- package/docs/api-reference.md +34 -1
- package/docs/merchant-quickstart.md +19 -2
- package/docs/payment-lifecycle.md +85 -0
- package/docs/pricing.md +6 -6
- package/docs/quickstart-10-minutes.md +145 -0
- package/docs/troubleshooting.md +70 -0
- package/examples/express-checkout.ts +14 -0
- package/examples/hosted-checkout-python/.env.example +5 -0
- package/examples/hosted-checkout-python/README.md +21 -0
- package/examples/hosted-checkout-python/app.py +124 -0
- package/examples/hosted-checkout-python/order_store.py +42 -0
- package/examples/hosted-checkout-python/pyproject.toml +9 -0
- package/examples/hosted-checkout-typescript/.env.example +5 -0
- package/examples/hosted-checkout-typescript/README.md +21 -0
- package/examples/hosted-checkout-typescript/package.json +20 -0
- package/examples/hosted-checkout-typescript/src/order-store.ts +52 -0
- package/examples/hosted-checkout-typescript/src/server.ts +139 -0
- package/examples/hosted-checkout-typescript/tsconfig.json +13 -0
- package/package.json +12 -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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type OrderStatus = "created" | "pending" | "paid" | "fulfilled_unsettled" | "review_required";
|
|
2
|
+
|
|
3
|
+
export interface Order {
|
|
4
|
+
id: string;
|
|
5
|
+
amount_minor: number;
|
|
6
|
+
currency: "JPY" | "USD";
|
|
7
|
+
payment_attempt: number;
|
|
8
|
+
siglume_challenge_hash?: string;
|
|
9
|
+
siglume_checkout_session_id?: string;
|
|
10
|
+
siglume_requirement_id?: string;
|
|
11
|
+
siglume_chain_receipt_id?: string;
|
|
12
|
+
siglume_payment_status: OrderStatus;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const orders = new Map<string, Order>([
|
|
16
|
+
[
|
|
17
|
+
"order_123",
|
|
18
|
+
{
|
|
19
|
+
id: "order_123",
|
|
20
|
+
amount_minor: 1200,
|
|
21
|
+
currency: "JPY",
|
|
22
|
+
payment_attempt: 0,
|
|
23
|
+
siglume_payment_status: "created",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const processedWebhookEvents = new Set<string>();
|
|
29
|
+
|
|
30
|
+
export function getOrder(orderId: string): Order | undefined {
|
|
31
|
+
return orders.get(orderId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function allOrders(): Order[] {
|
|
35
|
+
return [...orders.values()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveOrder(order: Order): void {
|
|
39
|
+
orders.set(order.id, order);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function findOrderByChallengeHash(challengeHash: string): Order | undefined {
|
|
43
|
+
return [...orders.values()].find((order) => order.siglume_challenge_hash === challengeHash);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function markWebhookEventProcessedOnce(eventId: string): boolean {
|
|
47
|
+
if (processedWebhookEvents.has(eventId)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
processedWebhookEvents.add(eventId);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
import express from "express";
|
|
4
|
+
import {
|
|
5
|
+
classifyDirectPaymentConfirmation,
|
|
6
|
+
DirectRequestPaymentMerchantClient,
|
|
7
|
+
HostedCheckoutNotAvailableError,
|
|
8
|
+
verifyDirectRequestPaymentWebhook,
|
|
9
|
+
} from "@siglume/direct-request-payment";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
allOrders,
|
|
13
|
+
findOrderByChallengeHash,
|
|
14
|
+
getOrder,
|
|
15
|
+
markWebhookEventProcessedOnce,
|
|
16
|
+
saveOrder,
|
|
17
|
+
} from "./order-store.js";
|
|
18
|
+
|
|
19
|
+
const app = express();
|
|
20
|
+
const port = Number(process.env.PORT || 3000);
|
|
21
|
+
const merchantKey = process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT || "example_merchant";
|
|
22
|
+
const shopOrigin = process.env.SHOP_PUBLIC_ORIGIN || "https://www.example.com";
|
|
23
|
+
|
|
24
|
+
const siglumeMerchant = new DirectRequestPaymentMerchantClient({
|
|
25
|
+
auth_token: process.env.SIGLUME_MERCHANT_AUTH_TOKEN,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const asyncRoute =
|
|
29
|
+
(handler: express.RequestHandler): express.RequestHandler =>
|
|
30
|
+
(req, res, next) => {
|
|
31
|
+
Promise.resolve(handler(req, res, next)).catch(next);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
app.get("/orders", (_req, res) => {
|
|
35
|
+
res.json({ orders: allOrders() });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
app.post(
|
|
39
|
+
"/checkout/siglume/start",
|
|
40
|
+
express.json(),
|
|
41
|
+
asyncRoute(async (req, res) => {
|
|
42
|
+
const orderId = String(req.body?.order_id || "");
|
|
43
|
+
const order = getOrder(orderId);
|
|
44
|
+
if (!order) {
|
|
45
|
+
res.status(404).json({ error: "order_not_found" });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
order.payment_attempt += 1;
|
|
50
|
+
const session = await siglumeMerchant.createCheckoutSession({
|
|
51
|
+
merchant: merchantKey,
|
|
52
|
+
amount_minor: order.amount_minor,
|
|
53
|
+
currency: order.currency,
|
|
54
|
+
nonce: `${order.id}-attempt_${order.payment_attempt}`,
|
|
55
|
+
success_url: `${shopOrigin}/thanks`,
|
|
56
|
+
cancel_url: `${shopOrigin}/cart`,
|
|
57
|
+
metadata: { order_id: order.id },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
order.siglume_challenge_hash = session.challenge_hash;
|
|
61
|
+
order.siglume_checkout_session_id = session.session_id;
|
|
62
|
+
order.siglume_payment_status = "pending";
|
|
63
|
+
saveOrder(order);
|
|
64
|
+
|
|
65
|
+
res.json({
|
|
66
|
+
order_id: order.id,
|
|
67
|
+
amount_minor: order.amount_minor,
|
|
68
|
+
currency: order.currency,
|
|
69
|
+
checkout_url: session.checkout_url,
|
|
70
|
+
session_id: session.session_id,
|
|
71
|
+
});
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
app.post(
|
|
76
|
+
"/siglume/webhook",
|
|
77
|
+
express.raw({ type: "application/json" }),
|
|
78
|
+
asyncRoute(async (req, res) => {
|
|
79
|
+
const { event } = await verifyDirectRequestPaymentWebhook(
|
|
80
|
+
process.env.SIGLUME_WEBHOOK_SECRET || "",
|
|
81
|
+
req.body,
|
|
82
|
+
req.header("siglume-signature") || "",
|
|
83
|
+
);
|
|
84
|
+
|
|
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);
|
|
107
|
+
}
|
|
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
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
res.status(204).send();
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
127
|
+
if (error instanceof HostedCheckoutNotAvailableError) {
|
|
128
|
+
res.status(409).json({ error: "hosted_checkout_not_enabled" });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.error("checkout starter error:", {
|
|
132
|
+
name: error instanceof Error ? error.name : "Error",
|
|
133
|
+
});
|
|
134
|
+
res.status(500).json({ error: "internal_error" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.listen(port, () => {
|
|
138
|
+
console.log(`Siglume Hosted Checkout starter listening on http://localhost:${port}`);
|
|
139
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts"]
|
|
13
|
+
}
|
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,8 +56,15 @@
|
|
|
52
56
|
},
|
|
53
57
|
"files": [
|
|
54
58
|
"dist",
|
|
59
|
+
"bin",
|
|
55
60
|
"docs",
|
|
56
61
|
"examples",
|
|
62
|
+
"templates",
|
|
63
|
+
"!examples/**/node_modules",
|
|
64
|
+
"!examples/**/__pycache__",
|
|
65
|
+
"!examples/**/*.pyc",
|
|
66
|
+
"!templates/**/__pycache__",
|
|
67
|
+
"!templates/**/*.pyc",
|
|
57
68
|
"README.md",
|
|
58
69
|
"CHANGELOG.md",
|
|
59
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)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request
|
|
8
|
+
from fastapi.responses import JSONResponse, Response
|
|
9
|
+
from siglume_direct_request_payment import (
|
|
10
|
+
DirectRequestPaymentMerchantClient,
|
|
11
|
+
HostedCheckoutNotAvailableError,
|
|
12
|
+
classify_direct_payment_confirmation,
|
|
13
|
+
verify_direct_request_payment_webhook,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SiglumeSdrpOrderStore(Protocol):
|
|
18
|
+
async def get_order_for_checkout(self, order_id: str, request: Request) -> dict[str, Any] | None: ...
|
|
19
|
+
async def mark_checkout_pending(self, *, order_id: str, challenge_hash: str, checkout_session_id: str) -> None: ...
|
|
20
|
+
async def record_webhook_event_once(self, event_id: str) -> bool: ...
|
|
21
|
+
async def find_order_by_challenge_hash(self, challenge_hash: str) -> dict[str, Any] | None: ...
|
|
22
|
+
async def mark_order_paid_once(self, *, order_id: str, requirement_id: str, chain_receipt_id: str) -> None: ...
|
|
23
|
+
async def mark_order_fulfilled_unsettled_once(self, *, order_id: str, requirement_id: str, pricing_band: str) -> None: ...
|
|
24
|
+
async def flag_payment_review(self, data: dict[str, Any]) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_siglume_sdrp_router(order_store: SiglumeSdrpOrderStore) -> APIRouter:
|
|
28
|
+
router = APIRouter()
|
|
29
|
+
merchant_key = os.environ["SIGLUME_DIRECT_PAYMENT_MERCHANT"]
|
|
30
|
+
shop_origin = os.environ["SHOP_PUBLIC_ORIGIN"]
|
|
31
|
+
merchant = DirectRequestPaymentMerchantClient(
|
|
32
|
+
auth_token=os.environ["SIGLUME_MERCHANT_AUTH_TOKEN"],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@router.post("/checkout/siglume/start")
|
|
36
|
+
async def start_checkout(request: Request) -> JSONResponse:
|
|
37
|
+
body = await request.json()
|
|
38
|
+
order_id = str(body.get("order_id") or "")
|
|
39
|
+
order = await order_store.get_order_for_checkout(order_id, request)
|
|
40
|
+
if not order:
|
|
41
|
+
return JSONResponse({"error": "order_not_found"}, status_code=404)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
session = merchant.create_checkout_session(
|
|
45
|
+
merchant=merchant_key,
|
|
46
|
+
amount_minor=int(order["amount_minor"]),
|
|
47
|
+
currency=str(order["currency"]),
|
|
48
|
+
nonce=f"{order['id']}-attempt_{int(time.time() * 1000)}",
|
|
49
|
+
success_url=f"{shop_origin}/checkout/siglume/success",
|
|
50
|
+
cancel_url=f"{shop_origin}/checkout/siglume/cancel",
|
|
51
|
+
metadata={"order_id": order["id"]},
|
|
52
|
+
)
|
|
53
|
+
except HostedCheckoutNotAvailableError:
|
|
54
|
+
return JSONResponse({"error": "hosted_checkout_not_enabled"}, status_code=409)
|
|
55
|
+
|
|
56
|
+
await order_store.mark_checkout_pending(
|
|
57
|
+
order_id=str(order["id"]),
|
|
58
|
+
challenge_hash=session["challenge_hash"],
|
|
59
|
+
checkout_session_id=session["session_id"],
|
|
60
|
+
)
|
|
61
|
+
return JSONResponse({"checkout_url": session["checkout_url"], "session_id": session["session_id"]})
|
|
62
|
+
|
|
63
|
+
@router.post("/webhooks/siglume")
|
|
64
|
+
async def siglume_webhook(request: Request) -> Response:
|
|
65
|
+
event = verify_direct_request_payment_webhook(
|
|
66
|
+
os.environ["SIGLUME_WEBHOOK_SECRET"],
|
|
67
|
+
await request.body(),
|
|
68
|
+
request.headers.get("Siglume-Signature", ""),
|
|
69
|
+
)["event"]
|
|
70
|
+
|
|
71
|
+
if not await order_store.record_webhook_event_once(str(event["id"])):
|
|
72
|
+
return Response(status_code=204)
|
|
73
|
+
|
|
74
|
+
if event["type"] == "direct_payment.confirmed":
|
|
75
|
+
confirmation = classify_direct_payment_confirmation(event)
|
|
76
|
+
if confirmation["kind"] == "standard_settled":
|
|
77
|
+
order = await order_store.find_order_by_challenge_hash(confirmation["challenge_hash"])
|
|
78
|
+
if order:
|
|
79
|
+
await order_store.mark_order_paid_once(
|
|
80
|
+
order_id=str(order["id"]),
|
|
81
|
+
requirement_id=confirmation["requirement_id"],
|
|
82
|
+
chain_receipt_id=confirmation["chain_receipt_id"],
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
await order_store.flag_payment_review({
|
|
86
|
+
"reason": "unknown_challenge_hash",
|
|
87
|
+
"requirement_id": confirmation["requirement_id"],
|
|
88
|
+
})
|
|
89
|
+
elif confirmation["kind"] == "metered_usage_accepted":
|
|
90
|
+
order = await order_store.find_order_by_challenge_hash(confirmation["challenge_hash"])
|
|
91
|
+
if order:
|
|
92
|
+
await order_store.mark_order_fulfilled_unsettled_once(
|
|
93
|
+
order_id=str(order["id"]),
|
|
94
|
+
requirement_id=confirmation["requirement_id"],
|
|
95
|
+
pricing_band=confirmation["pricing_band"],
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
await order_store.flag_payment_review({
|
|
99
|
+
"reason": "unknown_metered_challenge_hash",
|
|
100
|
+
"requirement_id": confirmation["requirement_id"],
|
|
101
|
+
})
|
|
102
|
+
else:
|
|
103
|
+
await order_store.flag_payment_review(dict(confirmation))
|
|
104
|
+
|
|
105
|
+
return Response(status_code=204)
|
|
106
|
+
|
|
107
|
+
return router
|