@vobs/payment 0.1.0 → 0.1.1
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/dist/handlers.d.ts +4 -1
- package/dist/handlers.js +16 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/manager.d.ts +3 -1
- package/dist/manager.js +54 -14
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/dist/handlers.d.ts
CHANGED
|
@@ -29,5 +29,8 @@ export interface HttpRequestContext {
|
|
|
29
29
|
}
|
|
30
30
|
type RouteResult = HttpResponse;
|
|
31
31
|
/** Create a dispatch function (pure logic, no framework dependency). */
|
|
32
|
-
export
|
|
32
|
+
export interface PaymentHttpDispatchOptions {
|
|
33
|
+
readonly authorize?: (action: 'close' | 'confirm', orderId: string, context: HttpRequestContext) => boolean | Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
export declare function paymentHttpDispatch(manager: PaymentManager, options?: PaymentHttpDispatchOptions): (ctx: HttpRequestContext) => Promise<RouteResult>;
|
|
33
36
|
export {};
|
package/dist/handlers.js
CHANGED
|
@@ -28,12 +28,17 @@ function errorStatus(err) {
|
|
|
28
28
|
return 409;
|
|
29
29
|
case 'channel_unavailable':
|
|
30
30
|
return 503;
|
|
31
|
+
case 'configuration_error':
|
|
32
|
+
return 500;
|
|
31
33
|
default:
|
|
32
34
|
return 500;
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
export function paymentHttpDispatch(manager, options = {}) {
|
|
38
|
+
const authorize = options.authorize;
|
|
39
|
+
async function isAuthorized(action, orderId, context) {
|
|
40
|
+
return (await authorize?.(action, orderId, context)) === true;
|
|
41
|
+
}
|
|
37
42
|
return async function dispatch(ctx) {
|
|
38
43
|
const { method, path } = ctx;
|
|
39
44
|
if (method === 'GET' && path === '/payment/available-methods') {
|
|
@@ -47,6 +52,9 @@ export function paymentHttpDispatch(manager) {
|
|
|
47
52
|
catch {
|
|
48
53
|
return json(400, { error: 'bad_json' });
|
|
49
54
|
}
|
|
55
|
+
if (!req || typeof req !== 'object' || Array.isArray(req)) {
|
|
56
|
+
return json(400, { error: 'bad_request' });
|
|
57
|
+
}
|
|
50
58
|
const outcome = await manager.createOrder(req);
|
|
51
59
|
if ('error' in outcome) {
|
|
52
60
|
return json(errorStatus(outcome), outcome);
|
|
@@ -64,6 +72,9 @@ export function paymentHttpDispatch(manager) {
|
|
|
64
72
|
const closeMatch = path.match(/^\/api\/payment\/orders\/([^/]+)\/close$/);
|
|
65
73
|
if (closeMatch && method === 'POST') {
|
|
66
74
|
const orderId = closeMatch[1];
|
|
75
|
+
if (!(await isAuthorized('close', orderId, ctx))) {
|
|
76
|
+
return json(403, { error: 'forbidden' });
|
|
77
|
+
}
|
|
67
78
|
const status = manager.close(orderId);
|
|
68
79
|
if (!status)
|
|
69
80
|
return json(404, { error: 'order_not_found' });
|
|
@@ -72,6 +83,9 @@ export function paymentHttpDispatch(manager) {
|
|
|
72
83
|
const confirmMatch = path.match(/^\/api\/payment\/orders\/([^/]+)\/confirm$/);
|
|
73
84
|
if (confirmMatch && method === 'POST') {
|
|
74
85
|
const orderId = confirmMatch[1];
|
|
86
|
+
if (!(await isAuthorized('confirm', orderId, ctx))) {
|
|
87
|
+
return json(403, { error: 'forbidden' });
|
|
88
|
+
}
|
|
75
89
|
const status = manager.confirm(orderId);
|
|
76
90
|
if (!status)
|
|
77
91
|
return json(404, { error: 'order_not_found' });
|
package/dist/index.d.ts
CHANGED
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
*
|
|
15
15
|
* const manager = createPaymentManager({
|
|
16
16
|
* apiKey: process.env.PAYMENT_API_KEY!,
|
|
17
|
+
* getNotifyUrl: (driver) => `https://pay.example.com/payment/callback/${driver}`,
|
|
17
18
|
* getOrderAmount: (orderId) => orderDao.find(orderId)?.amount, // server-side pricing (red line)
|
|
18
19
|
* onVerified: ({ orderId }) => orderDao.markPaid(orderId), // idempotent
|
|
19
20
|
* })
|
|
20
21
|
* manager.registerDriver(createMockDriver(store)) // swap in a real channel driver for P1
|
|
21
|
-
* const dispatch = paymentHttpDispatch(manager)
|
|
22
|
+
* const dispatch = paymentHttpDispatch(manager, { authorize: requireAdmin })
|
|
22
23
|
* // in your server: app.post('/api/payment/orders', async (req) => dispatch({ method:'POST', path:'/api/payment/orders', body: await req.text() }))
|
|
23
24
|
* ```
|
|
24
25
|
*/
|
package/dist/index.js
CHANGED
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
*
|
|
15
15
|
* const manager = createPaymentManager({
|
|
16
16
|
* apiKey: process.env.PAYMENT_API_KEY!,
|
|
17
|
+
* getNotifyUrl: (driver) => `https://pay.example.com/payment/callback/${driver}`,
|
|
17
18
|
* getOrderAmount: (orderId) => orderDao.find(orderId)?.amount, // server-side pricing (red line)
|
|
18
19
|
* onVerified: ({ orderId }) => orderDao.markPaid(orderId), // idempotent
|
|
19
20
|
* })
|
|
20
21
|
* manager.registerDriver(createMockDriver(store)) // swap in a real channel driver for P1
|
|
21
|
-
* const dispatch = paymentHttpDispatch(manager)
|
|
22
|
+
* const dispatch = paymentHttpDispatch(manager, { authorize: requireAdmin })
|
|
22
23
|
* // in your server: app.post('/api/payment/orders', async (req) => dispatch({ method:'POST', path:'/api/payment/orders', body: await req.text() }))
|
|
23
24
|
* ```
|
|
24
25
|
*/
|
package/dist/manager.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { AuditEntry, CallbackResult, CreateOrderRequest, CreateOrderResult, GetOrderAmount, OrderState, OrderStatus, PaymentAlert, PaymentConfig, PaymentDriver, PaymentScene, VerifiedResult } from './types.js';
|
|
18
18
|
import { type PaymentStore } from './store.js';
|
|
19
|
-
export type OrderErrorCode = 'order_not_found' | 'amount_mismatch' | 'amount_exceeds_limit' | 'order_conflict' | 'channel_unavailable' | 'bad_request';
|
|
19
|
+
export type OrderErrorCode = 'order_not_found' | 'amount_mismatch' | 'amount_exceeds_limit' | 'order_conflict' | 'channel_unavailable' | 'configuration_error' | 'bad_request';
|
|
20
20
|
export type OrderError = {
|
|
21
21
|
readonly error: OrderErrorCode;
|
|
22
22
|
readonly message?: string;
|
|
@@ -30,6 +30,8 @@ export interface PaymentManagerOptions {
|
|
|
30
30
|
readonly store?: PaymentStore;
|
|
31
31
|
/** Callback signature key (HMAC-SHA256). Inject via KMS/env in production. */
|
|
32
32
|
readonly apiKey?: string;
|
|
33
|
+
/** Build the public callback URL for a channel driver. Required for non-zero-amount orders. */
|
|
34
|
+
readonly getNotifyUrl?: (driverName: string) => string;
|
|
33
35
|
/** Server-pricing hook (business: orderId → amount in fen; undefined = order not found). */
|
|
34
36
|
readonly getOrderAmount?: GetOrderAmount;
|
|
35
37
|
/** Business action after booking (deliver/activate) — must be idempotent; on failure the business must retry or compensate manually. */
|
package/dist/manager.js
CHANGED
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
import { applyClose, applyConfirm, applyPaidCallback } from './state-machine.js';
|
|
18
18
|
import { createMemoryStore } from './store.js';
|
|
19
19
|
import { verifySignedCallback } from './verify.js';
|
|
20
|
-
const DEFAULT_API_KEY = 'dev-secret-0123456789abcdef';
|
|
21
20
|
export function createPaymentManager(options = {}) {
|
|
22
21
|
const config = options.config ?? {};
|
|
23
22
|
const store = options.store ?? createMemoryStore();
|
|
24
|
-
const apiKey = options.apiKey
|
|
23
|
+
const apiKey = options.apiKey;
|
|
25
24
|
const getOrderAmount = options.getOrderAmount;
|
|
25
|
+
const getNotifyUrl = options.getNotifyUrl;
|
|
26
26
|
const drivers = new Map();
|
|
27
|
+
const orderLocks = new Map();
|
|
27
28
|
const notifyVerified = options.onVerified;
|
|
28
29
|
const notifyAlert = options.onAlert;
|
|
29
30
|
const audit = (entry) => {
|
|
@@ -50,8 +51,37 @@ export function createPaymentManager(options = {}) {
|
|
|
50
51
|
createdAt: Date.now(),
|
|
51
52
|
};
|
|
52
53
|
}
|
|
54
|
+
async function withOrderLock(orderId, operation) {
|
|
55
|
+
const previous = orderLocks.get(orderId);
|
|
56
|
+
let release;
|
|
57
|
+
const current = new Promise((resolve) => {
|
|
58
|
+
release = resolve;
|
|
59
|
+
});
|
|
60
|
+
orderLocks.set(orderId, current);
|
|
61
|
+
await previous;
|
|
62
|
+
try {
|
|
63
|
+
return await operation();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
release?.();
|
|
67
|
+
if (orderLocks.get(orderId) === current)
|
|
68
|
+
orderLocks.delete(orderId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
53
71
|
async function createOrder(req) {
|
|
72
|
+
return withOrderLock(req.orderId, () => createOrderLocked(req));
|
|
73
|
+
}
|
|
74
|
+
async function createOrderLocked(req) {
|
|
54
75
|
const orderId = req.orderId;
|
|
76
|
+
if (!orderId || !req.subject || !Number.isSafeInteger(req.amount) || req.amount < 0) {
|
|
77
|
+
return {
|
|
78
|
+
error: 'bad_request',
|
|
79
|
+
message: 'orderId, subject, and a non-negative integer amount are required',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (!getOrderAmount) {
|
|
83
|
+
return { error: 'configuration_error', message: 'getOrderAmount is required' };
|
|
84
|
+
}
|
|
55
85
|
// ① idempotency: same idempotencyKey returns the same order
|
|
56
86
|
if (req.idempotencyKey) {
|
|
57
87
|
const existingId = store.getOrderByIdempotencyKey(req.idempotencyKey);
|
|
@@ -69,15 +99,18 @@ export function createPaymentManager(options = {}) {
|
|
|
69
99
|
}
|
|
70
100
|
}
|
|
71
101
|
// ② server-side pricing: amount from business (security red line — never trust the client amount)
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
80
|
-
}
|
|
102
|
+
const expected = await getOrderAmount(orderId);
|
|
103
|
+
if (expected === undefined) {
|
|
104
|
+
return { error: 'order_not_found' };
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isSafeInteger(expected) || expected < 0) {
|
|
107
|
+
return {
|
|
108
|
+
error: 'configuration_error',
|
|
109
|
+
message: 'getOrderAmount must return a non-negative integer amount',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (req.amount !== expected) {
|
|
113
|
+
return { error: 'amount_mismatch', expected };
|
|
81
114
|
}
|
|
82
115
|
// ③ per-order limit: reject over-limit (avoids channel errors/risk control)
|
|
83
116
|
if (config.maxAmount && expected > config.maxAmount) {
|
|
@@ -86,7 +119,7 @@ export function createPaymentManager(options = {}) {
|
|
|
86
119
|
// ④ duplicate guard: an unpaid order already exists
|
|
87
120
|
const existingOrder = store.getOrder(orderId);
|
|
88
121
|
if (existingOrder && existingOrder.state === 'created') {
|
|
89
|
-
const strategy = config.duplicateOrderStrategy ?? 'ask';
|
|
122
|
+
const strategy = req.duplicateStrategy ?? config.duplicateOrderStrategy ?? 'ask';
|
|
90
123
|
if (strategy === 'ask') {
|
|
91
124
|
return {
|
|
92
125
|
kind: 'duplicate',
|
|
@@ -131,6 +164,10 @@ export function createPaymentManager(options = {}) {
|
|
|
131
164
|
}
|
|
132
165
|
// ⑥ place the order via the channel
|
|
133
166
|
const driver = driverOf(req.driver ?? availableDrivers()[0]?.name ?? '');
|
|
167
|
+
const notifyUrl = getNotifyUrl?.(driver.name);
|
|
168
|
+
if (!notifyUrl) {
|
|
169
|
+
return { error: 'configuration_error', message: 'getNotifyUrl is required for paid orders' };
|
|
170
|
+
}
|
|
134
171
|
let init;
|
|
135
172
|
try {
|
|
136
173
|
init = await driver.createOrder({
|
|
@@ -138,14 +175,14 @@ export function createPaymentManager(options = {}) {
|
|
|
138
175
|
amount: expected,
|
|
139
176
|
subject: req.subject,
|
|
140
177
|
scene: req.scene ?? 'pc',
|
|
141
|
-
notifyUrl
|
|
178
|
+
notifyUrl,
|
|
142
179
|
});
|
|
143
180
|
}
|
|
144
181
|
catch (error) {
|
|
145
182
|
alert({ level: 'error', code: 'channel_error', message: String(error), driver: driver.name });
|
|
146
183
|
return { error: 'channel_unavailable', message: String(error) };
|
|
147
184
|
}
|
|
148
|
-
const record = newOrder({ orderId, amount: expected, subject: req.subject, scene: req.scene ?? 'pc', notifyUrl
|
|
185
|
+
const record = newOrder({ orderId, amount: expected, subject: req.subject, scene: req.scene ?? 'pc', notifyUrl }, driver, 'created');
|
|
149
186
|
record.init = init;
|
|
150
187
|
store.saveOrder(record);
|
|
151
188
|
if (req.idempotencyKey)
|
|
@@ -269,6 +306,9 @@ export function createPaymentManager(options = {}) {
|
|
|
269
306
|
const signature = ctx.headers['x-signature'];
|
|
270
307
|
const isGenericProtocol = Boolean(timestamp && nonce && signature);
|
|
271
308
|
if (isGenericProtocol) {
|
|
309
|
+
if (!apiKey) {
|
|
310
|
+
return { ok: false, status: 500, body: { ok: false, error: 'api_key_not_configured' } };
|
|
311
|
+
}
|
|
272
312
|
// generic verification protocol (X-* headers + HMAC-SHA256): for mock/self-built backends
|
|
273
313
|
const verified = await verifySignedCallback({
|
|
274
314
|
apiKey,
|
package/dist/types.d.ts
CHANGED
|
@@ -104,6 +104,7 @@ export interface CreateOrderRequest {
|
|
|
104
104
|
readonly subject: string;
|
|
105
105
|
readonly scene?: PaymentScene;
|
|
106
106
|
readonly driver?: string;
|
|
107
|
+
readonly duplicateStrategy?: 'reuse' | 'replace' | 'reject' | 'ask';
|
|
107
108
|
readonly idempotencyKey?: string;
|
|
108
109
|
}
|
|
109
110
|
/** Create-order result. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vobs/payment",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Payment factory backend SDK for vobs — channel drivers, order state machine, callback verification (signature + replay protection), server-side pricing and amount risk control (Experimental).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|