@vobs/payment 0.1.1 → 0.3.0
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/alipay-official.js +24 -13
- package/dist/handlers.d.ts +12 -3
- package/dist/handlers.js +7 -11
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/jeepay.js +1 -0
- package/dist/lakala.js +1 -0
- package/dist/manager.d.ts +5 -3
- package/dist/manager.js +234 -135
- package/dist/mock-driver.js +1 -0
- package/dist/store.d.ts +12 -2
- package/dist/store.js +38 -6
- package/dist/types.d.ts +12 -2
- package/dist/wechat-official.js +5 -4
- package/package.json +2 -2
package/dist/alipay-official.js
CHANGED
|
@@ -59,7 +59,7 @@ export function createAlipayOfficialDriver(store, options) {
|
|
|
59
59
|
.map((k) => `${k}=${params[k]}`)
|
|
60
60
|
.join('&');
|
|
61
61
|
}
|
|
62
|
-
async function gateway(method, bizContent) {
|
|
62
|
+
async function gateway(method, bizContent, notifyUrl = '') {
|
|
63
63
|
const params = {
|
|
64
64
|
app_id: options.appId,
|
|
65
65
|
method,
|
|
@@ -68,12 +68,13 @@ export function createAlipayOfficialDriver(store, options) {
|
|
|
68
68
|
timestamp: nowTimestamp(),
|
|
69
69
|
version: '1.0',
|
|
70
70
|
biz_content: JSON.stringify(bizContent),
|
|
71
|
+
...(notifyUrl ? { notify_url: notifyUrl } : {}),
|
|
71
72
|
};
|
|
72
73
|
params['sign'] = rsaSign(sortParams(params));
|
|
73
74
|
const res = await fetchImpl(baseUrl, {
|
|
74
75
|
method: 'POST',
|
|
75
|
-
headers: { 'Content-Type': 'application/
|
|
76
|
-
body:
|
|
76
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
|
77
|
+
body: new URLSearchParams(params).toString(),
|
|
77
78
|
});
|
|
78
79
|
const body = (await res.json());
|
|
79
80
|
if (!res.ok) {
|
|
@@ -81,7 +82,7 @@ export function createAlipayOfficialDriver(store, options) {
|
|
|
81
82
|
}
|
|
82
83
|
return body;
|
|
83
84
|
}
|
|
84
|
-
function buildPageUrl(method, bizContent) {
|
|
85
|
+
function buildPageUrl(method, bizContent, notifyUrl) {
|
|
85
86
|
const params = {
|
|
86
87
|
app_id: options.appId,
|
|
87
88
|
method,
|
|
@@ -91,7 +92,7 @@ export function createAlipayOfficialDriver(store, options) {
|
|
|
91
92
|
version: '1.0',
|
|
92
93
|
biz_content: JSON.stringify(bizContent),
|
|
93
94
|
return_url: '',
|
|
94
|
-
notify_url:
|
|
95
|
+
notify_url: notifyUrl,
|
|
95
96
|
};
|
|
96
97
|
params['sign'] = rsaSign(sortParams(params));
|
|
97
98
|
const qs = Object.keys(params)
|
|
@@ -108,7 +109,7 @@ export function createAlipayOfficialDriver(store, options) {
|
|
|
108
109
|
};
|
|
109
110
|
if (input.scene === 'pc') {
|
|
110
111
|
// Face-to-face (scan) payment
|
|
111
|
-
const body = await gateway('alipay.trade.precreate', biz);
|
|
112
|
+
const body = await gateway('alipay.trade.precreate', biz, input.notifyUrl);
|
|
112
113
|
const resp = body.alipay_trade_precreate_response;
|
|
113
114
|
if (resp?.code !== '10000' || !resp.qr_code) {
|
|
114
115
|
throw new Error(`alipay_precreate_failed:${resp?.sub_code ?? resp?.msg ?? 'unknown'}`);
|
|
@@ -120,21 +121,27 @@ export function createAlipayOfficialDriver(store, options) {
|
|
|
120
121
|
};
|
|
121
122
|
}
|
|
122
123
|
// h5 / miniapp: mobile web payment (redirect to cashier)
|
|
123
|
-
return { kind: 'redirect', payUrl: buildPageUrl('alipay.trade.wap.pay', biz) };
|
|
124
|
+
return { kind: 'redirect', payUrl: buildPageUrl('alipay.trade.wap.pay', biz, input.notifyUrl) };
|
|
124
125
|
}
|
|
125
126
|
function verifyCallback(payload, _headers) {
|
|
126
127
|
// Async notify is a form-encoded string: a=1&b=2&sign=... (URL-decoded)
|
|
127
128
|
let params;
|
|
128
129
|
if (typeof payload === 'string') {
|
|
129
130
|
params = {};
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
try {
|
|
132
|
+
for (const pair of payload.split('&')) {
|
|
133
|
+
const eq = pair.indexOf('=');
|
|
134
|
+
if (eq > 0) {
|
|
135
|
+
const decode = (value) => decodeURIComponent(value.replace(/\+/gu, ' '));
|
|
136
|
+
const k = decode(pair.slice(0, eq));
|
|
137
|
+
const v = decode(pair.slice(eq + 1));
|
|
138
|
+
params[k] = v;
|
|
139
|
+
}
|
|
136
140
|
}
|
|
137
141
|
}
|
|
142
|
+
catch {
|
|
143
|
+
return { ok: false, reason: 'bad_payload' };
|
|
144
|
+
}
|
|
138
145
|
}
|
|
139
146
|
else {
|
|
140
147
|
const obj = payload;
|
|
@@ -146,6 +153,9 @@ export function createAlipayOfficialDriver(store, options) {
|
|
|
146
153
|
}
|
|
147
154
|
// notify_id anti-replay (channel self-verification path)
|
|
148
155
|
const notifyId = params['notify_id'] ?? '';
|
|
156
|
+
if (notifyId.length === 0) {
|
|
157
|
+
return { ok: false, reason: 'bad_payload' };
|
|
158
|
+
}
|
|
149
159
|
if (!store.addNonce(`ali:${notifyId}`)) {
|
|
150
160
|
return { ok: false, reason: 'nonce_reused' };
|
|
151
161
|
}
|
|
@@ -187,6 +197,7 @@ export function createAlipayOfficialDriver(store, options) {
|
|
|
187
197
|
return {
|
|
188
198
|
name: 'alipay_official',
|
|
189
199
|
scenes: ['pc', 'h5', 'miniapp'],
|
|
200
|
+
callbackVerification: 'driver',
|
|
190
201
|
createOrder,
|
|
191
202
|
verifyCallback,
|
|
192
203
|
queryOrder,
|
package/dist/handlers.d.ts
CHANGED
|
@@ -27,10 +27,19 @@ export interface HttpRequestContext {
|
|
|
27
27
|
readonly headers?: Readonly<Record<string, string | undefined>>;
|
|
28
28
|
readonly body?: string;
|
|
29
29
|
}
|
|
30
|
-
type
|
|
31
|
-
/** Create a dispatch function (pure logic, no framework dependency). */
|
|
30
|
+
export type PaymentProtectedRoute = 'status' | 'close' | 'confirm';
|
|
32
31
|
export interface PaymentHttpDispatchOptions {
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Authorizes order reads and mutations. The dispatcher denies these routes when omitted;
|
|
34
|
+
* the host owns authentication, CSRF protection, and order ownership checks.
|
|
35
|
+
*/
|
|
36
|
+
readonly authorize?: (context: {
|
|
37
|
+
readonly route: PaymentProtectedRoute;
|
|
38
|
+
readonly orderId: string;
|
|
39
|
+
readonly request: HttpRequestContext;
|
|
40
|
+
}) => boolean | Promise<boolean>;
|
|
34
41
|
}
|
|
42
|
+
type RouteResult = HttpResponse;
|
|
43
|
+
/** Create a dispatch function (pure logic, no framework dependency). */
|
|
35
44
|
export declare function paymentHttpDispatch(manager: PaymentManager, options?: PaymentHttpDispatchOptions): (ctx: HttpRequestContext) => Promise<RouteResult>;
|
|
36
45
|
export {};
|
package/dist/handlers.js
CHANGED
|
@@ -28,17 +28,13 @@ function errorStatus(err) {
|
|
|
28
28
|
return 409;
|
|
29
29
|
case 'channel_unavailable':
|
|
30
30
|
return 503;
|
|
31
|
-
case 'configuration_error':
|
|
32
|
-
return 500;
|
|
33
31
|
default:
|
|
34
32
|
return 500;
|
|
35
33
|
}
|
|
36
34
|
}
|
|
35
|
+
/** Create a dispatch function (pure logic, no framework dependency). */
|
|
37
36
|
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
|
+
const authorize = async (route, orderId, request) => (await options.authorize?.({ route, orderId, request })) === true;
|
|
42
38
|
return async function dispatch(ctx) {
|
|
43
39
|
const { method, path } = ctx;
|
|
44
40
|
if (method === 'GET' && path === '/payment/available-methods') {
|
|
@@ -52,7 +48,7 @@ export function paymentHttpDispatch(manager, options = {}) {
|
|
|
52
48
|
catch {
|
|
53
49
|
return json(400, { error: 'bad_json' });
|
|
54
50
|
}
|
|
55
|
-
if (
|
|
51
|
+
if (typeof req !== 'object' || req === null || Array.isArray(req)) {
|
|
56
52
|
return json(400, { error: 'bad_request' });
|
|
57
53
|
}
|
|
58
54
|
const outcome = await manager.createOrder(req);
|
|
@@ -64,6 +60,8 @@ export function paymentHttpDispatch(manager, options = {}) {
|
|
|
64
60
|
const statusMatch = path.match(/^\/api\/payment\/orders\/([^/]+)\/status$/);
|
|
65
61
|
if (statusMatch && method === 'GET') {
|
|
66
62
|
const orderId = statusMatch[1];
|
|
63
|
+
if (!(await authorize('status', orderId, ctx)))
|
|
64
|
+
return json(403, { error: 'forbidden' });
|
|
67
65
|
const status = manager.getStatus(orderId);
|
|
68
66
|
if (!status)
|
|
69
67
|
return json(404, { error: 'order_not_found' });
|
|
@@ -72,9 +70,8 @@ export function paymentHttpDispatch(manager, options = {}) {
|
|
|
72
70
|
const closeMatch = path.match(/^\/api\/payment\/orders\/([^/]+)\/close$/);
|
|
73
71
|
if (closeMatch && method === 'POST') {
|
|
74
72
|
const orderId = closeMatch[1];
|
|
75
|
-
if (!(await
|
|
73
|
+
if (!(await authorize('close', orderId, ctx)))
|
|
76
74
|
return json(403, { error: 'forbidden' });
|
|
77
|
-
}
|
|
78
75
|
const status = manager.close(orderId);
|
|
79
76
|
if (!status)
|
|
80
77
|
return json(404, { error: 'order_not_found' });
|
|
@@ -83,9 +80,8 @@ export function paymentHttpDispatch(manager, options = {}) {
|
|
|
83
80
|
const confirmMatch = path.match(/^\/api\/payment\/orders\/([^/]+)\/confirm$/);
|
|
84
81
|
if (confirmMatch && method === 'POST') {
|
|
85
82
|
const orderId = confirmMatch[1];
|
|
86
|
-
if (!(await
|
|
83
|
+
if (!(await authorize('confirm', orderId, ctx)))
|
|
87
84
|
return json(403, { error: 'forbidden' });
|
|
88
|
-
}
|
|
89
85
|
const status = manager.confirm(orderId);
|
|
90
86
|
if (!status)
|
|
91
87
|
return json(404, { error: 'order_not_found' });
|
package/dist/index.d.ts
CHANGED
|
@@ -14,12 +14,13 @@
|
|
|
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}`,
|
|
18
17
|
* getOrderAmount: (orderId) => orderDao.find(orderId)?.amount, // server-side pricing (red line)
|
|
19
18
|
* onVerified: ({ orderId }) => orderDao.markPaid(orderId), // idempotent
|
|
20
19
|
* })
|
|
21
20
|
* manager.registerDriver(createMockDriver(store)) // swap in a real channel driver for P1
|
|
22
|
-
* const dispatch = paymentHttpDispatch(manager, {
|
|
21
|
+
* const dispatch = paymentHttpDispatch(manager, {
|
|
22
|
+
* authorize: ({ route, orderId, request }) => access.canPay(request, route, orderId),
|
|
23
|
+
* })
|
|
23
24
|
* // in your server: app.post('/api/payment/orders', async (req) => dispatch({ method:'POST', path:'/api/payment/orders', body: await req.text() }))
|
|
24
25
|
* ```
|
|
25
26
|
*/
|
package/dist/index.js
CHANGED
|
@@ -14,12 +14,13 @@
|
|
|
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}`,
|
|
18
17
|
* getOrderAmount: (orderId) => orderDao.find(orderId)?.amount, // server-side pricing (red line)
|
|
19
18
|
* onVerified: ({ orderId }) => orderDao.markPaid(orderId), // idempotent
|
|
20
19
|
* })
|
|
21
20
|
* manager.registerDriver(createMockDriver(store)) // swap in a real channel driver for P1
|
|
22
|
-
* const dispatch = paymentHttpDispatch(manager, {
|
|
21
|
+
* const dispatch = paymentHttpDispatch(manager, {
|
|
22
|
+
* authorize: ({ route, orderId, request }) => access.canPay(request, route, orderId),
|
|
23
|
+
* })
|
|
23
24
|
* // in your server: app.post('/api/payment/orders', async (req) => dispatch({ method:'POST', path:'/api/payment/orders', body: await req.text() }))
|
|
24
25
|
* ```
|
|
25
26
|
*/
|
package/dist/jeepay.js
CHANGED
package/dist/lakala.js
CHANGED
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' | '
|
|
19
|
+
export type OrderErrorCode = 'order_not_found' | 'amount_mismatch' | 'amount_exceeds_limit' | 'order_conflict' | 'channel_unavailable' | 'bad_request';
|
|
20
20
|
export type OrderError = {
|
|
21
21
|
readonly error: OrderErrorCode;
|
|
22
22
|
readonly message?: string;
|
|
@@ -30,8 +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
|
-
/**
|
|
34
|
-
readonly
|
|
33
|
+
/** Server-owned callback URL, or a resolver for channel-specific callback URLs. */
|
|
34
|
+
readonly notifyUrl?: string | ((driverName: string) => string);
|
|
35
35
|
/** Server-pricing hook (business: orderId → amount in fen; undefined = order not found). */
|
|
36
36
|
readonly getOrderAmount?: GetOrderAmount;
|
|
37
37
|
/** Business action after booking (deliver/activate) — must be idempotent; on failure the business must retry or compensate manually. */
|
|
@@ -56,6 +56,8 @@ export interface PaymentManager {
|
|
|
56
56
|
readonly orderId: string;
|
|
57
57
|
readonly state: OrderState;
|
|
58
58
|
}[];
|
|
59
|
+
/** Retries a persisted pending business delivery. Returns undefined when the order is missing. */
|
|
60
|
+
retryDelivery(orderId: string): Promise<boolean | undefined>;
|
|
59
61
|
handleCallback(driverName: string, ctx: {
|
|
60
62
|
readonly headers: Readonly<Record<string, string | undefined>>;
|
|
61
63
|
readonly body: string;
|
package/dist/manager.js
CHANGED
|
@@ -17,22 +17,103 @@
|
|
|
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
|
+
function isValidAmount(value) {
|
|
21
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
22
|
+
}
|
|
23
|
+
function isValidCreateOrderRequest(req) {
|
|
24
|
+
if (typeof req !== 'object' || req === null || Array.isArray(req))
|
|
25
|
+
return false;
|
|
26
|
+
const value = req;
|
|
27
|
+
return (typeof value.orderId === 'string' &&
|
|
28
|
+
value.orderId.trim().length > 0 &&
|
|
29
|
+
typeof value.subject === 'string' &&
|
|
30
|
+
value.subject.trim().length > 0 &&
|
|
31
|
+
isValidAmount(value.amount) &&
|
|
32
|
+
(value.idempotencyKey === undefined ||
|
|
33
|
+
(typeof value.idempotencyKey === 'string' && value.idempotencyKey.length > 0)));
|
|
34
|
+
}
|
|
20
35
|
export function createPaymentManager(options = {}) {
|
|
21
36
|
const config = options.config ?? {};
|
|
22
|
-
const store = options.store ??
|
|
37
|
+
const store = options.store ??
|
|
38
|
+
createMemoryStore({
|
|
39
|
+
...(config.idempotencyTtlMs === undefined ? {} : { retentionMs: config.idempotencyTtlMs }),
|
|
40
|
+
});
|
|
23
41
|
const apiKey = options.apiKey;
|
|
42
|
+
const configuredNotifyUrl = options.notifyUrl;
|
|
24
43
|
const getOrderAmount = options.getOrderAmount;
|
|
25
|
-
const getNotifyUrl = options.getNotifyUrl;
|
|
26
44
|
const drivers = new Map();
|
|
27
|
-
const
|
|
45
|
+
const pendingOrderCreates = new Map();
|
|
46
|
+
const pendingIdempotencyCreates = new Map();
|
|
47
|
+
const pendingDeliveries = new Map();
|
|
28
48
|
const notifyVerified = options.onVerified;
|
|
29
49
|
const notifyAlert = options.onAlert;
|
|
50
|
+
const notifyUrlFor = (driverName) => {
|
|
51
|
+
const value = typeof configuredNotifyUrl === 'function'
|
|
52
|
+
? configuredNotifyUrl(driverName)
|
|
53
|
+
: configuredNotifyUrl;
|
|
54
|
+
return typeof value === 'string' ? value : '';
|
|
55
|
+
};
|
|
30
56
|
const audit = (entry) => {
|
|
31
57
|
store.appendAudit(entry);
|
|
32
58
|
};
|
|
33
59
|
const alert = (alert) => {
|
|
34
60
|
notifyAlert?.(alert);
|
|
35
61
|
};
|
|
62
|
+
const verifiedResultFor = (order) => ({
|
|
63
|
+
orderId: order.orderId,
|
|
64
|
+
state: 'paid',
|
|
65
|
+
...(order.paidAt ? { paidAt: order.paidAt } : {}),
|
|
66
|
+
...(order.channelTxnId ? { channelTxnId: order.channelTxnId } : {}),
|
|
67
|
+
});
|
|
68
|
+
async function deliverVerified(order) {
|
|
69
|
+
if (notifyVerified === undefined || order.deliveryState === 'delivered')
|
|
70
|
+
return true;
|
|
71
|
+
order.deliveryState = 'pending';
|
|
72
|
+
delete order.deliveryError;
|
|
73
|
+
store.saveOrder(order);
|
|
74
|
+
try {
|
|
75
|
+
await notifyVerified(verifiedResultFor(order));
|
|
76
|
+
order.deliveryState = 'delivered';
|
|
77
|
+
store.saveOrder(order);
|
|
78
|
+
audit({ operation: 'delivery', orderId: order.orderId, reason: 'delivered', at: Date.now() });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
order.deliveryState = 'pending';
|
|
83
|
+
order.deliveryError = String(error);
|
|
84
|
+
store.saveOrder(order);
|
|
85
|
+
audit({ operation: 'delivery', orderId: order.orderId, reason: 'failed', at: Date.now() });
|
|
86
|
+
alert({
|
|
87
|
+
level: 'error',
|
|
88
|
+
code: 'delivery_failed',
|
|
89
|
+
message: String(error),
|
|
90
|
+
orderId: order.orderId,
|
|
91
|
+
});
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function scheduleDelivery(order) {
|
|
96
|
+
const pending = pendingDeliveries.get(order.orderId);
|
|
97
|
+
if (pending !== undefined)
|
|
98
|
+
return pending;
|
|
99
|
+
const delivery = deliverVerified(order)
|
|
100
|
+
.catch((error) => {
|
|
101
|
+
alert({
|
|
102
|
+
level: 'error',
|
|
103
|
+
code: 'delivery_failed',
|
|
104
|
+
message: String(error),
|
|
105
|
+
orderId: order.orderId,
|
|
106
|
+
});
|
|
107
|
+
return false;
|
|
108
|
+
})
|
|
109
|
+
.finally(() => {
|
|
110
|
+
if (pendingDeliveries.get(order.orderId) === delivery) {
|
|
111
|
+
pendingDeliveries.delete(order.orderId);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
pendingDeliveries.set(order.orderId, delivery);
|
|
115
|
+
return delivery;
|
|
116
|
+
}
|
|
36
117
|
function driverOf(name) {
|
|
37
118
|
const driver = drivers.get(name);
|
|
38
119
|
if (!driver)
|
|
@@ -51,36 +132,39 @@ export function createPaymentManager(options = {}) {
|
|
|
51
132
|
createdAt: Date.now(),
|
|
52
133
|
};
|
|
53
134
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const current = new Promise((resolve) => {
|
|
58
|
-
release = resolve;
|
|
59
|
-
});
|
|
60
|
-
orderLocks.set(orderId, current);
|
|
61
|
-
await previous;
|
|
62
|
-
try {
|
|
63
|
-
return await operation();
|
|
135
|
+
function createOrder(req) {
|
|
136
|
+
if (!isValidCreateOrderRequest(req)) {
|
|
137
|
+
return Promise.resolve({ error: 'bad_request', message: 'Invalid payment order request' });
|
|
64
138
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
139
|
+
const pendingByKey = req.idempotencyKey === undefined
|
|
140
|
+
? undefined
|
|
141
|
+
: pendingIdempotencyCreates.get(req.idempotencyKey);
|
|
142
|
+
if (pendingByKey !== undefined)
|
|
143
|
+
return pendingByKey;
|
|
144
|
+
const pendingByOrder = pendingOrderCreates.get(req.orderId);
|
|
145
|
+
if (pendingByOrder !== undefined)
|
|
146
|
+
return pendingByOrder;
|
|
147
|
+
const operation = createOrderInternal(req);
|
|
148
|
+
pendingOrderCreates.set(req.orderId, operation);
|
|
149
|
+
if (req.idempotencyKey !== undefined) {
|
|
150
|
+
pendingIdempotencyCreates.set(req.idempotencyKey, operation);
|
|
69
151
|
}
|
|
152
|
+
const releasePending = () => {
|
|
153
|
+
if (pendingOrderCreates.get(req.orderId) === operation) {
|
|
154
|
+
pendingOrderCreates.delete(req.orderId);
|
|
155
|
+
}
|
|
156
|
+
if (req.idempotencyKey !== undefined &&
|
|
157
|
+
pendingIdempotencyCreates.get(req.idempotencyKey) === operation) {
|
|
158
|
+
pendingIdempotencyCreates.delete(req.idempotencyKey);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
void operation.then(releasePending, releasePending);
|
|
162
|
+
return operation;
|
|
70
163
|
}
|
|
71
|
-
async function
|
|
72
|
-
return withOrderLock(req.orderId, () => createOrderLocked(req));
|
|
73
|
-
}
|
|
74
|
-
async function createOrderLocked(req) {
|
|
164
|
+
async function createOrderInternal(req) {
|
|
75
165
|
const orderId = req.orderId;
|
|
76
|
-
if (
|
|
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' };
|
|
166
|
+
if (getOrderAmount === undefined) {
|
|
167
|
+
return { error: 'bad_request', message: 'Server-side order pricing is required' };
|
|
84
168
|
}
|
|
85
169
|
// ① idempotency: same idempotencyKey returns the same order
|
|
86
170
|
if (req.idempotencyKey) {
|
|
@@ -98,103 +182,122 @@ export function createPaymentManager(options = {}) {
|
|
|
98
182
|
}
|
|
99
183
|
}
|
|
100
184
|
}
|
|
101
|
-
// ② server-side pricing: amount from business (security red line — never trust the client amount)
|
|
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 };
|
|
114
|
-
}
|
|
115
|
-
// ③ per-order limit: reject over-limit (avoids channel errors/risk control)
|
|
116
|
-
if (config.maxAmount && expected > config.maxAmount) {
|
|
117
|
-
return { error: 'amount_exceeds_limit', max: config.maxAmount };
|
|
118
|
-
}
|
|
119
|
-
// ④ duplicate guard: an unpaid order already exists
|
|
120
185
|
const existingOrder = store.getOrder(orderId);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
186
|
+
const reserved = existingOrder === undefined && store.reserveOrder(orderId);
|
|
187
|
+
if (existingOrder === undefined && !reserved) {
|
|
188
|
+
return { error: 'order_conflict', message: `order is being created for ${orderId}` };
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
// ② server-side pricing: amount from business (security red line — never trust the client amount)
|
|
192
|
+
const expected = await getOrderAmount(orderId);
|
|
193
|
+
if (expected === undefined)
|
|
194
|
+
return { error: 'order_not_found' };
|
|
195
|
+
if (!isValidAmount(expected)) {
|
|
196
|
+
return { error: 'bad_request', message: 'Invalid server-side order amount' };
|
|
197
|
+
}
|
|
198
|
+
if (req.amount !== expected)
|
|
199
|
+
return { error: 'amount_mismatch', expected };
|
|
200
|
+
// ③ per-order limit: reject over-limit (avoids channel errors/risk control)
|
|
201
|
+
if (config.maxAmount !== undefined && expected > config.maxAmount) {
|
|
202
|
+
return { error: 'amount_exceeds_limit', max: config.maxAmount };
|
|
203
|
+
}
|
|
204
|
+
// ④ duplicate guard: an unpaid order already exists
|
|
205
|
+
if (existingOrder && existingOrder.state === 'created') {
|
|
206
|
+
const strategy = req.duplicateStrategy ?? config.duplicateOrderStrategy ?? 'ask';
|
|
207
|
+
if (strategy === 'ask') {
|
|
208
|
+
return {
|
|
209
|
+
kind: 'duplicate',
|
|
210
|
+
existingOrderId: existingOrder.orderId,
|
|
211
|
+
existingAmount: existingOrder.amount,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (strategy === 'reuse') {
|
|
215
|
+
return {
|
|
216
|
+
kind: 'ok',
|
|
217
|
+
driver: existingOrder.driver,
|
|
218
|
+
orderId: existingOrder.orderId,
|
|
219
|
+
state: existingOrder.state,
|
|
220
|
+
...(existingOrder.init ? { init: existingOrder.init } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (strategy === 'reject') {
|
|
224
|
+
return { error: 'order_conflict', message: `active order exists for ${orderId}` };
|
|
225
|
+
}
|
|
226
|
+
existingOrder.state = 'closed';
|
|
227
|
+
store.saveOrder(existingOrder);
|
|
228
|
+
audit({
|
|
229
|
+
operation: 'close',
|
|
230
|
+
orderId,
|
|
231
|
+
from: 'created',
|
|
232
|
+
to: 'closed',
|
|
233
|
+
reason: 'duplicate_replace',
|
|
234
|
+
at: Date.now(),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// ⑤ zero-amount: skip the channel, mark paid directly
|
|
238
|
+
if (expected === 0) {
|
|
239
|
+
const driver = driverOf(req.driver ?? availableDrivers()[0]?.name ?? '');
|
|
240
|
+
const record = newOrder({ orderId, amount: 0, subject: req.subject, scene: req.scene ?? 'pc', notifyUrl: '' }, driver, 'paid');
|
|
241
|
+
record.paidAt = new Date().toISOString();
|
|
242
|
+
store.saveOrder(record);
|
|
243
|
+
if (req.idempotencyKey)
|
|
244
|
+
store.setIdempotencyKey(req.idempotencyKey, orderId);
|
|
245
|
+
audit({ operation: 'create', orderId, to: 'paid', reason: 'zero_amount', at: Date.now() });
|
|
246
|
+
void scheduleDelivery(record);
|
|
247
|
+
return { kind: 'ok', driver: driver.name, orderId, state: 'paid' };
|
|
129
248
|
}
|
|
130
|
-
|
|
249
|
+
// ⑥ place the order via the channel
|
|
250
|
+
const driver = driverOf(req.driver ?? availableDrivers()[0]?.name ?? '');
|
|
251
|
+
const notifyUrl = notifyUrlFor(driver.name);
|
|
252
|
+
if (driver.callbackVerification === 'driver' && notifyUrl.length === 0) {
|
|
131
253
|
return {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
orderId: existingOrder.orderId,
|
|
135
|
-
state: existingOrder.state,
|
|
136
|
-
...(existingOrder.init ? { init: existingOrder.init } : {}),
|
|
254
|
+
error: 'bad_request',
|
|
255
|
+
message: 'A server-owned notifyUrl is required for driver-verified callbacks',
|
|
137
256
|
};
|
|
138
257
|
}
|
|
139
|
-
|
|
140
|
-
|
|
258
|
+
let init;
|
|
259
|
+
try {
|
|
260
|
+
init = await driver.createOrder({
|
|
261
|
+
orderId,
|
|
262
|
+
amount: expected,
|
|
263
|
+
subject: req.subject,
|
|
264
|
+
scene: req.scene ?? 'pc',
|
|
265
|
+
notifyUrl,
|
|
266
|
+
});
|
|
141
267
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
// ⑤ zero-amount: skip the channel, mark paid directly
|
|
154
|
-
if (expected === 0) {
|
|
155
|
-
const driver = driverOf(req.driver ?? availableDrivers()[0]?.name ?? '');
|
|
156
|
-
const record = newOrder({ orderId, amount: 0, subject: req.subject, scene: req.scene ?? 'pc', notifyUrl: '' }, driver, 'paid');
|
|
157
|
-
record.paidAt = new Date().toISOString();
|
|
158
|
-
store.saveOrder(record);
|
|
159
|
-
if (req.idempotencyKey)
|
|
160
|
-
store.setIdempotencyKey(req.idempotencyKey, orderId);
|
|
161
|
-
audit({ operation: 'create', orderId, to: 'paid', reason: 'zero_amount', at: Date.now() });
|
|
162
|
-
void notifyVerified?.({ orderId, state: 'paid', paidAt: record.paidAt });
|
|
163
|
-
return { kind: 'ok', driver: driver.name, orderId, state: 'paid' };
|
|
164
|
-
}
|
|
165
|
-
// ⑥ place the order via the channel
|
|
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
|
-
}
|
|
171
|
-
let init;
|
|
172
|
-
try {
|
|
173
|
-
init = await driver.createOrder({
|
|
268
|
+
catch (error) {
|
|
269
|
+
alert({
|
|
270
|
+
level: 'error',
|
|
271
|
+
code: 'channel_error',
|
|
272
|
+
message: String(error),
|
|
273
|
+
driver: driver.name,
|
|
274
|
+
});
|
|
275
|
+
return { error: 'channel_unavailable', message: String(error) };
|
|
276
|
+
}
|
|
277
|
+
const record = newOrder({
|
|
174
278
|
orderId,
|
|
175
279
|
amount: expected,
|
|
176
280
|
subject: req.subject,
|
|
177
281
|
scene: req.scene ?? 'pc',
|
|
178
282
|
notifyUrl,
|
|
179
|
-
});
|
|
283
|
+
}, driver, 'created');
|
|
284
|
+
record.init = init;
|
|
285
|
+
store.saveOrder(record);
|
|
286
|
+
if (req.idempotencyKey)
|
|
287
|
+
store.setIdempotencyKey(req.idempotencyKey, orderId);
|
|
288
|
+
audit({ operation: 'create', orderId, to: 'created', at: Date.now() });
|
|
289
|
+
return {
|
|
290
|
+
kind: 'ok',
|
|
291
|
+
driver: driver.name,
|
|
292
|
+
orderId,
|
|
293
|
+
state: 'created',
|
|
294
|
+
...(init ? { init } : {}),
|
|
295
|
+
};
|
|
180
296
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
297
|
+
finally {
|
|
298
|
+
if (reserved)
|
|
299
|
+
store.releaseOrderReservation(orderId);
|
|
184
300
|
}
|
|
185
|
-
const record = newOrder({ orderId, amount: expected, subject: req.subject, scene: req.scene ?? 'pc', notifyUrl }, driver, 'created');
|
|
186
|
-
record.init = init;
|
|
187
|
-
store.saveOrder(record);
|
|
188
|
-
if (req.idempotencyKey)
|
|
189
|
-
store.setIdempotencyKey(req.idempotencyKey, orderId);
|
|
190
|
-
audit({ operation: 'create', orderId, to: 'created', at: Date.now() });
|
|
191
|
-
return {
|
|
192
|
-
kind: 'ok',
|
|
193
|
-
driver: driver.name,
|
|
194
|
-
orderId,
|
|
195
|
-
state: 'created',
|
|
196
|
-
...(init ? { init } : {}),
|
|
197
|
-
};
|
|
198
301
|
}
|
|
199
302
|
function availableDrivers() {
|
|
200
303
|
return Array.from(drivers.values()).map((d) => ({ name: d.name, scenes: d.scenes }));
|
|
@@ -213,6 +316,7 @@ export function createPaymentManager(options = {}) {
|
|
|
213
316
|
const { state, ok } = applyClose(order.state);
|
|
214
317
|
if (ok) {
|
|
215
318
|
order.state = state;
|
|
319
|
+
store.saveOrder(order);
|
|
216
320
|
audit({ operation: 'close', orderId, from: before, to: state, at: Date.now() });
|
|
217
321
|
}
|
|
218
322
|
return { state: order.state, ...(order.paidAt ? { paidAt: order.paidAt } : {}) };
|
|
@@ -226,13 +330,9 @@ export function createPaymentManager(options = {}) {
|
|
|
226
330
|
if (ok) {
|
|
227
331
|
order.state = state;
|
|
228
332
|
order.paidAt = new Date().toISOString();
|
|
333
|
+
store.saveOrder(order);
|
|
229
334
|
audit({ operation: 'confirm', orderId, from: before, to: state, at: Date.now() });
|
|
230
|
-
void
|
|
231
|
-
orderId,
|
|
232
|
-
state: 'paid',
|
|
233
|
-
paidAt: order.paidAt,
|
|
234
|
-
...(order.channelTxnId ? { channelTxnId: order.channelTxnId } : {}),
|
|
235
|
-
});
|
|
335
|
+
void scheduleDelivery(order);
|
|
236
336
|
}
|
|
237
337
|
return { state: order.state, ...(order.paidAt ? { paidAt: order.paidAt } : {}) };
|
|
238
338
|
}
|
|
@@ -253,6 +353,7 @@ export function createPaymentManager(options = {}) {
|
|
|
253
353
|
if (transition.state === 'paid') {
|
|
254
354
|
order.paidAt = new Date().toISOString();
|
|
255
355
|
}
|
|
356
|
+
store.saveOrder(order);
|
|
256
357
|
audit({
|
|
257
358
|
operation: 'callback',
|
|
258
359
|
orderId,
|
|
@@ -262,12 +363,7 @@ export function createPaymentManager(options = {}) {
|
|
|
262
363
|
at: Date.now(),
|
|
263
364
|
});
|
|
264
365
|
if (transition.state === 'paid') {
|
|
265
|
-
void
|
|
266
|
-
orderId,
|
|
267
|
-
state: 'paid',
|
|
268
|
-
...(order.paidAt ? { paidAt: order.paidAt } : {}),
|
|
269
|
-
...(order.channelTxnId ? { channelTxnId: order.channelTxnId } : {}),
|
|
270
|
-
});
|
|
366
|
+
void scheduleDelivery(order);
|
|
271
367
|
}
|
|
272
368
|
}
|
|
273
369
|
}
|
|
@@ -282,6 +378,7 @@ export function createPaymentManager(options = {}) {
|
|
|
282
378
|
const { state, ok } = applyClose(order.state);
|
|
283
379
|
if (ok) {
|
|
284
380
|
order.state = state;
|
|
381
|
+
store.saveOrder(order);
|
|
285
382
|
audit({
|
|
286
383
|
operation: 'close',
|
|
287
384
|
orderId: order.orderId,
|
|
@@ -304,10 +401,10 @@ export function createPaymentManager(options = {}) {
|
|
|
304
401
|
const timestamp = ctx.headers['x-timestamp'];
|
|
305
402
|
const nonce = ctx.headers['x-nonce'];
|
|
306
403
|
const signature = ctx.headers['x-signature'];
|
|
307
|
-
const isGenericProtocol =
|
|
404
|
+
const isGenericProtocol = driver.callbackVerification !== 'driver';
|
|
308
405
|
if (isGenericProtocol) {
|
|
309
|
-
if (
|
|
310
|
-
return { ok: false, status: 500, body: { ok: false, error: '
|
|
406
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
407
|
+
return { ok: false, status: 500, body: { ok: false, error: 'server_configuration' } };
|
|
311
408
|
}
|
|
312
409
|
// generic verification protocol (X-* headers + HMAC-SHA256): for mock/self-built backends
|
|
313
410
|
const verified = await verifySignedCallback({
|
|
@@ -384,14 +481,10 @@ export function createPaymentManager(options = {}) {
|
|
|
384
481
|
...(transition.reason ? { reason: transition.reason } : {}),
|
|
385
482
|
at: Date.now(),
|
|
386
483
|
});
|
|
484
|
+
store.saveOrder(order);
|
|
387
485
|
}
|
|
388
|
-
if (
|
|
389
|
-
void
|
|
390
|
-
orderId: order.orderId,
|
|
391
|
-
state: 'paid',
|
|
392
|
-
...(order.paidAt ? { paidAt: order.paidAt } : {}),
|
|
393
|
-
...(order.channelTxnId ? { channelTxnId: order.channelTxnId } : {}),
|
|
394
|
-
});
|
|
486
|
+
if (order.state === 'paid' && order.deliveryState !== 'delivered') {
|
|
487
|
+
void scheduleDelivery(order);
|
|
395
488
|
}
|
|
396
489
|
if (transition.state === 'frozen') {
|
|
397
490
|
alert({
|
|
@@ -433,6 +526,12 @@ export function createPaymentManager(options = {}) {
|
|
|
433
526
|
confirm,
|
|
434
527
|
syncOrderStatus,
|
|
435
528
|
scanExpiredOrders,
|
|
529
|
+
async retryDelivery(orderId) {
|
|
530
|
+
const order = store.getOrder(orderId);
|
|
531
|
+
if (order === undefined)
|
|
532
|
+
return undefined;
|
|
533
|
+
return scheduleDelivery(order);
|
|
534
|
+
},
|
|
436
535
|
handleCallback,
|
|
437
536
|
auditLog: () => store.auditLog(),
|
|
438
537
|
};
|
package/dist/mock-driver.js
CHANGED
package/dist/store.d.ts
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
import type { AuditEntry, OrderRecord } from './types.js';
|
|
10
10
|
export interface PaymentStore {
|
|
11
11
|
getOrder(orderId: string): OrderRecord | undefined;
|
|
12
|
+
/** Atomically reserves an order id before a channel call. Returns false when it is already reserved or persisted. */
|
|
13
|
+
reserveOrder(orderId: string): boolean;
|
|
14
|
+
/** Releases an unpersisted reservation after a failed create attempt. */
|
|
15
|
+
releaseOrderReservation(orderId: string): void;
|
|
12
16
|
saveOrder(order: OrderRecord): void;
|
|
13
17
|
/** Iterate all orders (for expiry scans; production implementations should paginate/index). */
|
|
14
18
|
allOrders(): readonly OrderRecord[];
|
|
@@ -19,5 +23,11 @@ export interface PaymentStore {
|
|
|
19
23
|
appendAudit(entry: AuditEntry): void;
|
|
20
24
|
auditLog(): readonly AuditEntry[];
|
|
21
25
|
}
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
export interface MemoryStoreOptions {
|
|
27
|
+
/** Retention for callback nonces and idempotency keys. Defaults to 24 hours. */
|
|
28
|
+
readonly retentionMs?: number;
|
|
29
|
+
/** Maximum retained audit entries. Defaults to 10,000. */
|
|
30
|
+
readonly maxAuditEntries?: number;
|
|
31
|
+
}
|
|
32
|
+
/** In-memory implementation. Production stores must back reserveOrder with a unique constraint or transaction. */
|
|
33
|
+
export declare function createMemoryStore(options?: MemoryStoreOptions): PaymentStore;
|
package/dist/store.js
CHANGED
|
@@ -6,36 +6,68 @@
|
|
|
6
6
|
* Order storage abstraction (P0 in-memory; for a production DB see the design doc's production checklist:
|
|
7
7
|
* unique constraints back idempotency, row locks/CAS prevent concurrent double-writes, idempotency records and nonces with TTL).
|
|
8
8
|
*/
|
|
9
|
-
/** In-memory implementation
|
|
10
|
-
export function createMemoryStore() {
|
|
9
|
+
/** In-memory implementation. Production stores must back reserveOrder with a unique constraint or transaction. */
|
|
10
|
+
export function createMemoryStore(options = {}) {
|
|
11
|
+
const retentionMs = options.retentionMs ?? 24 * 60 * 60 * 1000;
|
|
12
|
+
const maxAuditEntries = options.maxAuditEntries ?? 10_000;
|
|
11
13
|
const orders = new Map();
|
|
12
|
-
const
|
|
14
|
+
const reservations = new Set();
|
|
15
|
+
const nonces = new Map();
|
|
13
16
|
const idempotency = new Map();
|
|
14
17
|
const audit = [];
|
|
18
|
+
const pruneExpired = (now) => {
|
|
19
|
+
for (const [nonce, expiresAt] of nonces) {
|
|
20
|
+
if (expiresAt <= now)
|
|
21
|
+
nonces.delete(nonce);
|
|
22
|
+
}
|
|
23
|
+
for (const [key, entry] of idempotency) {
|
|
24
|
+
if (entry.expiresAt <= now)
|
|
25
|
+
idempotency.delete(key);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
15
28
|
return {
|
|
16
29
|
getOrder(orderId) {
|
|
17
30
|
return orders.get(orderId);
|
|
18
31
|
},
|
|
32
|
+
reserveOrder(orderId) {
|
|
33
|
+
if (orders.has(orderId) || reservations.has(orderId))
|
|
34
|
+
return false;
|
|
35
|
+
reservations.add(orderId);
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
releaseOrderReservation(orderId) {
|
|
39
|
+
reservations.delete(orderId);
|
|
40
|
+
},
|
|
19
41
|
saveOrder(order) {
|
|
20
42
|
orders.set(order.orderId, order);
|
|
43
|
+
reservations.delete(order.orderId);
|
|
21
44
|
},
|
|
22
45
|
allOrders() {
|
|
23
46
|
return Array.from(orders.values());
|
|
24
47
|
},
|
|
25
48
|
addNonce(nonce) {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
pruneExpired(now);
|
|
26
51
|
if (nonces.has(nonce))
|
|
27
52
|
return false;
|
|
28
|
-
nonces.
|
|
53
|
+
nonces.set(nonce, now + retentionMs);
|
|
29
54
|
return true;
|
|
30
55
|
},
|
|
31
56
|
getOrderByIdempotencyKey(key) {
|
|
32
|
-
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
pruneExpired(now);
|
|
59
|
+
return idempotency.get(key)?.orderId;
|
|
33
60
|
},
|
|
34
61
|
setIdempotencyKey(key, orderId) {
|
|
35
|
-
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
pruneExpired(now);
|
|
64
|
+
idempotency.set(key, { orderId, expiresAt: now + retentionMs });
|
|
36
65
|
},
|
|
37
66
|
appendAudit(entry) {
|
|
38
67
|
audit.push(entry);
|
|
68
|
+
if (audit.length > maxAuditEntries) {
|
|
69
|
+
audit.splice(0, audit.length - maxAuditEntries);
|
|
70
|
+
}
|
|
39
71
|
},
|
|
40
72
|
auditLog() {
|
|
41
73
|
return audit;
|
package/dist/types.d.ts
CHANGED
|
@@ -37,10 +37,17 @@ export type OrderInit = {
|
|
|
37
37
|
readonly kind: 'redirect';
|
|
38
38
|
readonly payUrl: string;
|
|
39
39
|
};
|
|
40
|
+
/** Callback authentication owner. Unspecified drivers use the manager HMAC protocol. */
|
|
41
|
+
export type PaymentCallbackVerification = 'manager-hmac' | 'driver';
|
|
40
42
|
/** Unified payment driver contract (three capabilities: createOrder / verifyCallback / queryOrder). */
|
|
41
43
|
export interface PaymentDriver {
|
|
42
44
|
readonly name: string;
|
|
43
45
|
readonly scenes: readonly PaymentScene[];
|
|
46
|
+
/**
|
|
47
|
+
* Declares who authenticates callbacks. Real channel drivers use `driver`; custom drivers
|
|
48
|
+
* default to the manager HMAC protocol and therefore require the X-* callback headers.
|
|
49
|
+
*/
|
|
50
|
+
readonly callbackVerification?: PaymentCallbackVerification;
|
|
44
51
|
/** Place an order: call the payment channel → return one-time payment params. */
|
|
45
52
|
createOrder(input: CreateOrderInput): Promise<OrderInit>;
|
|
46
53
|
/** Channel callback verification + normalization: NOTIFY request → verify channel signature → normalize order result. */
|
|
@@ -83,6 +90,9 @@ export interface OrderRecord {
|
|
|
83
90
|
channelTxnId?: string;
|
|
84
91
|
/** Large-amount audit pending reason (audit). */
|
|
85
92
|
auditReason?: string;
|
|
93
|
+
/** Business delivery state after a paid transition. Failed delivery remains pending for retry. */
|
|
94
|
+
deliveryState?: 'pending' | 'delivered';
|
|
95
|
+
deliveryError?: string;
|
|
86
96
|
}
|
|
87
97
|
/** Global policy config (PaymentManager-level, driver-agnostic). */
|
|
88
98
|
export interface PaymentConfig {
|
|
@@ -145,9 +155,9 @@ export interface PaymentAlert {
|
|
|
145
155
|
readonly orderId?: string;
|
|
146
156
|
readonly driver?: string;
|
|
147
157
|
}
|
|
148
|
-
/** Operation audit entry (create/callback-book/freeze/confirm all logged). */
|
|
158
|
+
/** Operation audit entry (create/callback-book/freeze/confirm/delivery all logged). */
|
|
149
159
|
export interface AuditEntry {
|
|
150
|
-
readonly operation: 'create' | 'callback' | 'close' | 'confirm' | 'freeze';
|
|
160
|
+
readonly operation: 'create' | 'callback' | 'close' | 'confirm' | 'freeze' | 'delivery';
|
|
151
161
|
readonly orderId?: string;
|
|
152
162
|
readonly from?: OrderState;
|
|
153
163
|
readonly to?: OrderState;
|
package/dist/wechat-official.js
CHANGED
|
@@ -123,10 +123,6 @@ export function createWechatOfficialDriver(store, options) {
|
|
|
123
123
|
if (Math.abs(Date.now() / 1000 - Number(ts)) > timeWindowSec) {
|
|
124
124
|
return { ok: false, reason: 'timestamp_expired' };
|
|
125
125
|
}
|
|
126
|
-
// nonce anti-replay (channel self-verify path; the driver dedups via the store)
|
|
127
|
-
if (!store.addNonce(`wx:${nonce}`)) {
|
|
128
|
-
return { ok: false, reason: 'nonce_reused' };
|
|
129
|
-
}
|
|
130
126
|
// optional: platform-cert verification (signed string = timestamp\nnonce\nbody\n)
|
|
131
127
|
if (platformKey) {
|
|
132
128
|
const rawBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
@@ -157,6 +153,10 @@ export function createWechatOfficialDriver(store, options) {
|
|
|
157
153
|
decipher.final(),
|
|
158
154
|
]).toString('utf8');
|
|
159
155
|
const data = JSON.parse(plain);
|
|
156
|
+
// Only consume the nonce after platform authentication and AES-GCM decryption succeed.
|
|
157
|
+
if (!store.addNonce(`wx:${nonce}`)) {
|
|
158
|
+
return { ok: false, reason: 'nonce_reused' };
|
|
159
|
+
}
|
|
160
160
|
if (data['trade_state'] !== 'SUCCESS') {
|
|
161
161
|
return { ok: false, reason: `trade_state:${String(data['trade_state'])}` };
|
|
162
162
|
}
|
|
@@ -199,6 +199,7 @@ export function createWechatOfficialDriver(store, options) {
|
|
|
199
199
|
return {
|
|
200
200
|
name: 'wechat_official',
|
|
201
201
|
scenes: ['pc', 'h5', 'miniapp'],
|
|
202
|
+
callbackVerification: 'driver',
|
|
202
203
|
createOrder,
|
|
203
204
|
verifyCallback,
|
|
204
205
|
queryOrder,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vobs/payment",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"vitest": "^4.1.11"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
|
-
"node": ">=
|
|
31
|
+
"node": ">=22.12.0"
|
|
32
32
|
},
|
|
33
33
|
"bugs": {
|
|
34
34
|
"url": "https://github.com/vobsjs/vobs/issues"
|