@vobs/payment 0.1.0 → 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 +13 -1
- package/dist/handlers.js +11 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/jeepay.js +1 -0
- package/dist/lakala.js +1 -0
- package/dist/manager.d.ts +4 -0
- package/dist/manager.js +237 -98
- 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 +13 -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,7 +27,19 @@ export interface HttpRequestContext {
|
|
|
27
27
|
readonly headers?: Readonly<Record<string, string | undefined>>;
|
|
28
28
|
readonly body?: string;
|
|
29
29
|
}
|
|
30
|
+
export type PaymentProtectedRoute = 'status' | 'close' | 'confirm';
|
|
31
|
+
export interface PaymentHttpDispatchOptions {
|
|
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>;
|
|
41
|
+
}
|
|
30
42
|
type RouteResult = HttpResponse;
|
|
31
43
|
/** Create a dispatch function (pure logic, no framework dependency). */
|
|
32
|
-
export declare function paymentHttpDispatch(manager: PaymentManager): (ctx: HttpRequestContext) => Promise<RouteResult>;
|
|
44
|
+
export declare function paymentHttpDispatch(manager: PaymentManager, options?: PaymentHttpDispatchOptions): (ctx: HttpRequestContext) => Promise<RouteResult>;
|
|
33
45
|
export {};
|
package/dist/handlers.js
CHANGED
|
@@ -33,7 +33,8 @@ function errorStatus(err) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
/** Create a dispatch function (pure logic, no framework dependency). */
|
|
36
|
-
export function paymentHttpDispatch(manager) {
|
|
36
|
+
export function paymentHttpDispatch(manager, options = {}) {
|
|
37
|
+
const authorize = async (route, orderId, request) => (await options.authorize?.({ route, orderId, request })) === true;
|
|
37
38
|
return async function dispatch(ctx) {
|
|
38
39
|
const { method, path } = ctx;
|
|
39
40
|
if (method === 'GET' && path === '/payment/available-methods') {
|
|
@@ -47,6 +48,9 @@ export function paymentHttpDispatch(manager) {
|
|
|
47
48
|
catch {
|
|
48
49
|
return json(400, { error: 'bad_json' });
|
|
49
50
|
}
|
|
51
|
+
if (typeof req !== 'object' || req === null || Array.isArray(req)) {
|
|
52
|
+
return json(400, { error: 'bad_request' });
|
|
53
|
+
}
|
|
50
54
|
const outcome = await manager.createOrder(req);
|
|
51
55
|
if ('error' in outcome) {
|
|
52
56
|
return json(errorStatus(outcome), outcome);
|
|
@@ -56,6 +60,8 @@ export function paymentHttpDispatch(manager) {
|
|
|
56
60
|
const statusMatch = path.match(/^\/api\/payment\/orders\/([^/]+)\/status$/);
|
|
57
61
|
if (statusMatch && method === 'GET') {
|
|
58
62
|
const orderId = statusMatch[1];
|
|
63
|
+
if (!(await authorize('status', orderId, ctx)))
|
|
64
|
+
return json(403, { error: 'forbidden' });
|
|
59
65
|
const status = manager.getStatus(orderId);
|
|
60
66
|
if (!status)
|
|
61
67
|
return json(404, { error: 'order_not_found' });
|
|
@@ -64,6 +70,8 @@ export function paymentHttpDispatch(manager) {
|
|
|
64
70
|
const closeMatch = path.match(/^\/api\/payment\/orders\/([^/]+)\/close$/);
|
|
65
71
|
if (closeMatch && method === 'POST') {
|
|
66
72
|
const orderId = closeMatch[1];
|
|
73
|
+
if (!(await authorize('close', orderId, ctx)))
|
|
74
|
+
return json(403, { error: 'forbidden' });
|
|
67
75
|
const status = manager.close(orderId);
|
|
68
76
|
if (!status)
|
|
69
77
|
return json(404, { error: 'order_not_found' });
|
|
@@ -72,6 +80,8 @@ export function paymentHttpDispatch(manager) {
|
|
|
72
80
|
const confirmMatch = path.match(/^\/api\/payment\/orders\/([^/]+)\/confirm$/);
|
|
73
81
|
if (confirmMatch && method === 'POST') {
|
|
74
82
|
const orderId = confirmMatch[1];
|
|
83
|
+
if (!(await authorize('confirm', orderId, ctx)))
|
|
84
|
+
return json(403, { error: 'forbidden' });
|
|
75
85
|
const status = manager.confirm(orderId);
|
|
76
86
|
if (!status)
|
|
77
87
|
return json(404, { error: 'order_not_found' });
|
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
* onVerified: ({ orderId }) => orderDao.markPaid(orderId), // idempotent
|
|
19
19
|
* })
|
|
20
20
|
* manager.registerDriver(createMockDriver(store)) // swap in a real channel driver for P1
|
|
21
|
-
* const dispatch = paymentHttpDispatch(manager
|
|
21
|
+
* const dispatch = paymentHttpDispatch(manager, {
|
|
22
|
+
* authorize: ({ route, orderId, request }) => access.canPay(request, route, orderId),
|
|
23
|
+
* })
|
|
22
24
|
* // in your server: app.post('/api/payment/orders', async (req) => dispatch({ method:'POST', path:'/api/payment/orders', body: await req.text() }))
|
|
23
25
|
* ```
|
|
24
26
|
*/
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
* onVerified: ({ orderId }) => orderDao.markPaid(orderId), // idempotent
|
|
19
19
|
* })
|
|
20
20
|
* manager.registerDriver(createMockDriver(store)) // swap in a real channel driver for P1
|
|
21
|
-
* const dispatch = paymentHttpDispatch(manager
|
|
21
|
+
* const dispatch = paymentHttpDispatch(manager, {
|
|
22
|
+
* authorize: ({ route, orderId, request }) => access.canPay(request, route, orderId),
|
|
23
|
+
* })
|
|
22
24
|
* // in your server: app.post('/api/payment/orders', async (req) => dispatch({ method:'POST', path:'/api/payment/orders', body: await req.text() }))
|
|
23
25
|
* ```
|
|
24
26
|
*/
|
package/dist/jeepay.js
CHANGED
package/dist/lakala.js
CHANGED
package/dist/manager.d.ts
CHANGED
|
@@ -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
|
+
/** Server-owned callback URL, or a resolver for channel-specific callback URLs. */
|
|
34
|
+
readonly notifyUrl?: string | ((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. */
|
|
@@ -54,6 +56,8 @@ export interface PaymentManager {
|
|
|
54
56
|
readonly orderId: string;
|
|
55
57
|
readonly state: OrderState;
|
|
56
58
|
}[];
|
|
59
|
+
/** Retries a persisted pending business delivery. Returns undefined when the order is missing. */
|
|
60
|
+
retryDelivery(orderId: string): Promise<boolean | undefined>;
|
|
57
61
|
handleCallback(driverName: string, ctx: {
|
|
58
62
|
readonly headers: Readonly<Record<string, string | undefined>>;
|
|
59
63
|
readonly body: string;
|
package/dist/manager.js
CHANGED
|
@@ -17,21 +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
|
-
|
|
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
|
+
}
|
|
21
35
|
export function createPaymentManager(options = {}) {
|
|
22
36
|
const config = options.config ?? {};
|
|
23
|
-
const store = options.store ??
|
|
24
|
-
|
|
37
|
+
const store = options.store ??
|
|
38
|
+
createMemoryStore({
|
|
39
|
+
...(config.idempotencyTtlMs === undefined ? {} : { retentionMs: config.idempotencyTtlMs }),
|
|
40
|
+
});
|
|
41
|
+
const apiKey = options.apiKey;
|
|
42
|
+
const configuredNotifyUrl = options.notifyUrl;
|
|
25
43
|
const getOrderAmount = options.getOrderAmount;
|
|
26
44
|
const drivers = new Map();
|
|
45
|
+
const pendingOrderCreates = new Map();
|
|
46
|
+
const pendingIdempotencyCreates = new Map();
|
|
47
|
+
const pendingDeliveries = new Map();
|
|
27
48
|
const notifyVerified = options.onVerified;
|
|
28
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
|
+
};
|
|
29
56
|
const audit = (entry) => {
|
|
30
57
|
store.appendAudit(entry);
|
|
31
58
|
};
|
|
32
59
|
const alert = (alert) => {
|
|
33
60
|
notifyAlert?.(alert);
|
|
34
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
|
+
}
|
|
35
117
|
function driverOf(name) {
|
|
36
118
|
const driver = drivers.get(name);
|
|
37
119
|
if (!driver)
|
|
@@ -50,8 +132,40 @@ export function createPaymentManager(options = {}) {
|
|
|
50
132
|
createdAt: Date.now(),
|
|
51
133
|
};
|
|
52
134
|
}
|
|
53
|
-
|
|
135
|
+
function createOrder(req) {
|
|
136
|
+
if (!isValidCreateOrderRequest(req)) {
|
|
137
|
+
return Promise.resolve({ error: 'bad_request', message: 'Invalid payment order request' });
|
|
138
|
+
}
|
|
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);
|
|
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;
|
|
163
|
+
}
|
|
164
|
+
async function createOrderInternal(req) {
|
|
54
165
|
const orderId = req.orderId;
|
|
166
|
+
if (getOrderAmount === undefined) {
|
|
167
|
+
return { error: 'bad_request', message: 'Server-side order pricing is required' };
|
|
168
|
+
}
|
|
55
169
|
// ① idempotency: same idempotencyKey returns the same order
|
|
56
170
|
if (req.idempotencyKey) {
|
|
57
171
|
const existingId = store.getOrderByIdempotencyKey(req.idempotencyKey);
|
|
@@ -68,96 +182,122 @@ export function createPaymentManager(options = {}) {
|
|
|
68
182
|
}
|
|
69
183
|
}
|
|
70
184
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
185
|
+
const existingOrder = store.getOrder(orderId);
|
|
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)
|
|
76
194
|
return { error: 'order_not_found' };
|
|
195
|
+
if (!isValidAmount(expected)) {
|
|
196
|
+
return { error: 'bad_request', message: 'Invalid server-side order amount' };
|
|
77
197
|
}
|
|
78
|
-
if (req.amount !== expected)
|
|
198
|
+
if (req.amount !== expected)
|
|
79
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 };
|
|
80
203
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
});
|
|
96
236
|
}
|
|
97
|
-
|
|
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' };
|
|
248
|
+
}
|
|
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) {
|
|
98
253
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
orderId: existingOrder.orderId,
|
|
102
|
-
state: existingOrder.state,
|
|
103
|
-
...(existingOrder.init ? { init: existingOrder.init } : {}),
|
|
254
|
+
error: 'bad_request',
|
|
255
|
+
message: 'A server-owned notifyUrl is required for driver-verified callbacks',
|
|
104
256
|
};
|
|
105
257
|
}
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
});
|
|
267
|
+
}
|
|
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) };
|
|
108
276
|
}
|
|
109
|
-
|
|
110
|
-
existingOrder.state = 'closed';
|
|
111
|
-
audit({
|
|
112
|
-
operation: 'close',
|
|
277
|
+
const record = newOrder({
|
|
113
278
|
orderId,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// ⑤ zero-amount: skip the channel, mark paid directly
|
|
121
|
-
if (expected === 0) {
|
|
122
|
-
const driver = driverOf(req.driver ?? availableDrivers()[0]?.name ?? '');
|
|
123
|
-
const record = newOrder({ orderId, amount: 0, subject: req.subject, scene: req.scene ?? 'pc', notifyUrl: '' }, driver, 'paid');
|
|
124
|
-
record.paidAt = new Date().toISOString();
|
|
279
|
+
amount: expected,
|
|
280
|
+
subject: req.subject,
|
|
281
|
+
scene: req.scene ?? 'pc',
|
|
282
|
+
notifyUrl,
|
|
283
|
+
}, driver, 'created');
|
|
284
|
+
record.init = init;
|
|
125
285
|
store.saveOrder(record);
|
|
126
286
|
if (req.idempotencyKey)
|
|
127
287
|
store.setIdempotencyKey(req.idempotencyKey, orderId);
|
|
128
|
-
audit({ operation: 'create', orderId, to: '
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// ⑥ place the order via the channel
|
|
133
|
-
const driver = driverOf(req.driver ?? availableDrivers()[0]?.name ?? '');
|
|
134
|
-
let init;
|
|
135
|
-
try {
|
|
136
|
-
init = await driver.createOrder({
|
|
288
|
+
audit({ operation: 'create', orderId, to: 'created', at: Date.now() });
|
|
289
|
+
return {
|
|
290
|
+
kind: 'ok',
|
|
291
|
+
driver: driver.name,
|
|
137
292
|
orderId,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
notifyUrl: '',
|
|
142
|
-
});
|
|
293
|
+
state: 'created',
|
|
294
|
+
...(init ? { init } : {}),
|
|
295
|
+
};
|
|
143
296
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
297
|
+
finally {
|
|
298
|
+
if (reserved)
|
|
299
|
+
store.releaseOrderReservation(orderId);
|
|
147
300
|
}
|
|
148
|
-
const record = newOrder({ orderId, amount: expected, subject: req.subject, scene: req.scene ?? 'pc', notifyUrl: '' }, driver, 'created');
|
|
149
|
-
record.init = init;
|
|
150
|
-
store.saveOrder(record);
|
|
151
|
-
if (req.idempotencyKey)
|
|
152
|
-
store.setIdempotencyKey(req.idempotencyKey, orderId);
|
|
153
|
-
audit({ operation: 'create', orderId, to: 'created', at: Date.now() });
|
|
154
|
-
return {
|
|
155
|
-
kind: 'ok',
|
|
156
|
-
driver: driver.name,
|
|
157
|
-
orderId,
|
|
158
|
-
state: 'created',
|
|
159
|
-
...(init ? { init } : {}),
|
|
160
|
-
};
|
|
161
301
|
}
|
|
162
302
|
function availableDrivers() {
|
|
163
303
|
return Array.from(drivers.values()).map((d) => ({ name: d.name, scenes: d.scenes }));
|
|
@@ -176,6 +316,7 @@ export function createPaymentManager(options = {}) {
|
|
|
176
316
|
const { state, ok } = applyClose(order.state);
|
|
177
317
|
if (ok) {
|
|
178
318
|
order.state = state;
|
|
319
|
+
store.saveOrder(order);
|
|
179
320
|
audit({ operation: 'close', orderId, from: before, to: state, at: Date.now() });
|
|
180
321
|
}
|
|
181
322
|
return { state: order.state, ...(order.paidAt ? { paidAt: order.paidAt } : {}) };
|
|
@@ -189,13 +330,9 @@ export function createPaymentManager(options = {}) {
|
|
|
189
330
|
if (ok) {
|
|
190
331
|
order.state = state;
|
|
191
332
|
order.paidAt = new Date().toISOString();
|
|
333
|
+
store.saveOrder(order);
|
|
192
334
|
audit({ operation: 'confirm', orderId, from: before, to: state, at: Date.now() });
|
|
193
|
-
void
|
|
194
|
-
orderId,
|
|
195
|
-
state: 'paid',
|
|
196
|
-
paidAt: order.paidAt,
|
|
197
|
-
...(order.channelTxnId ? { channelTxnId: order.channelTxnId } : {}),
|
|
198
|
-
});
|
|
335
|
+
void scheduleDelivery(order);
|
|
199
336
|
}
|
|
200
337
|
return { state: order.state, ...(order.paidAt ? { paidAt: order.paidAt } : {}) };
|
|
201
338
|
}
|
|
@@ -216,6 +353,7 @@ export function createPaymentManager(options = {}) {
|
|
|
216
353
|
if (transition.state === 'paid') {
|
|
217
354
|
order.paidAt = new Date().toISOString();
|
|
218
355
|
}
|
|
356
|
+
store.saveOrder(order);
|
|
219
357
|
audit({
|
|
220
358
|
operation: 'callback',
|
|
221
359
|
orderId,
|
|
@@ -225,12 +363,7 @@ export function createPaymentManager(options = {}) {
|
|
|
225
363
|
at: Date.now(),
|
|
226
364
|
});
|
|
227
365
|
if (transition.state === 'paid') {
|
|
228
|
-
void
|
|
229
|
-
orderId,
|
|
230
|
-
state: 'paid',
|
|
231
|
-
...(order.paidAt ? { paidAt: order.paidAt } : {}),
|
|
232
|
-
...(order.channelTxnId ? { channelTxnId: order.channelTxnId } : {}),
|
|
233
|
-
});
|
|
366
|
+
void scheduleDelivery(order);
|
|
234
367
|
}
|
|
235
368
|
}
|
|
236
369
|
}
|
|
@@ -245,6 +378,7 @@ export function createPaymentManager(options = {}) {
|
|
|
245
378
|
const { state, ok } = applyClose(order.state);
|
|
246
379
|
if (ok) {
|
|
247
380
|
order.state = state;
|
|
381
|
+
store.saveOrder(order);
|
|
248
382
|
audit({
|
|
249
383
|
operation: 'close',
|
|
250
384
|
orderId: order.orderId,
|
|
@@ -267,8 +401,11 @@ export function createPaymentManager(options = {}) {
|
|
|
267
401
|
const timestamp = ctx.headers['x-timestamp'];
|
|
268
402
|
const nonce = ctx.headers['x-nonce'];
|
|
269
403
|
const signature = ctx.headers['x-signature'];
|
|
270
|
-
const isGenericProtocol =
|
|
404
|
+
const isGenericProtocol = driver.callbackVerification !== 'driver';
|
|
271
405
|
if (isGenericProtocol) {
|
|
406
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
407
|
+
return { ok: false, status: 500, body: { ok: false, error: 'server_configuration' } };
|
|
408
|
+
}
|
|
272
409
|
// generic verification protocol (X-* headers + HMAC-SHA256): for mock/self-built backends
|
|
273
410
|
const verified = await verifySignedCallback({
|
|
274
411
|
apiKey,
|
|
@@ -344,14 +481,10 @@ export function createPaymentManager(options = {}) {
|
|
|
344
481
|
...(transition.reason ? { reason: transition.reason } : {}),
|
|
345
482
|
at: Date.now(),
|
|
346
483
|
});
|
|
484
|
+
store.saveOrder(order);
|
|
347
485
|
}
|
|
348
|
-
if (
|
|
349
|
-
void
|
|
350
|
-
orderId: order.orderId,
|
|
351
|
-
state: 'paid',
|
|
352
|
-
...(order.paidAt ? { paidAt: order.paidAt } : {}),
|
|
353
|
-
...(order.channelTxnId ? { channelTxnId: order.channelTxnId } : {}),
|
|
354
|
-
});
|
|
486
|
+
if (order.state === 'paid' && order.deliveryState !== 'delivered') {
|
|
487
|
+
void scheduleDelivery(order);
|
|
355
488
|
}
|
|
356
489
|
if (transition.state === 'frozen') {
|
|
357
490
|
alert({
|
|
@@ -393,6 +526,12 @@ export function createPaymentManager(options = {}) {
|
|
|
393
526
|
confirm,
|
|
394
527
|
syncOrderStatus,
|
|
395
528
|
scanExpiredOrders,
|
|
529
|
+
async retryDelivery(orderId) {
|
|
530
|
+
const order = store.getOrder(orderId);
|
|
531
|
+
if (order === undefined)
|
|
532
|
+
return undefined;
|
|
533
|
+
return scheduleDelivery(order);
|
|
534
|
+
},
|
|
396
535
|
handleCallback,
|
|
397
536
|
auditLog: () => store.auditLog(),
|
|
398
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 {
|
|
@@ -104,6 +114,7 @@ export interface CreateOrderRequest {
|
|
|
104
114
|
readonly subject: string;
|
|
105
115
|
readonly scene?: PaymentScene;
|
|
106
116
|
readonly driver?: string;
|
|
117
|
+
readonly duplicateStrategy?: 'reuse' | 'replace' | 'reject' | 'ask';
|
|
107
118
|
readonly idempotencyKey?: string;
|
|
108
119
|
}
|
|
109
120
|
/** Create-order result. */
|
|
@@ -144,9 +155,9 @@ export interface PaymentAlert {
|
|
|
144
155
|
readonly orderId?: string;
|
|
145
156
|
readonly driver?: string;
|
|
146
157
|
}
|
|
147
|
-
/** Operation audit entry (create/callback-book/freeze/confirm all logged). */
|
|
158
|
+
/** Operation audit entry (create/callback-book/freeze/confirm/delivery all logged). */
|
|
148
159
|
export interface AuditEntry {
|
|
149
|
-
readonly operation: 'create' | 'callback' | 'close' | 'confirm' | 'freeze';
|
|
160
|
+
readonly operation: 'create' | 'callback' | 'close' | 'confirm' | 'freeze' | 'delivery';
|
|
150
161
|
readonly orderId?: string;
|
|
151
162
|
readonly from?: OrderState;
|
|
152
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"
|