@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.
@@ -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/json' },
76
- body: JSON.stringify(params),
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
- for (const pair of payload.split('&')) {
131
- const eq = pair.indexOf('=');
132
- if (eq > 0) {
133
- const k = decodeURIComponent(pair.slice(0, eq));
134
- const v = decodeURIComponent(pair.slice(eq + 1));
135
- params[k] = v;
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,
@@ -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
@@ -197,6 +197,7 @@ export function createJeepayDriver(_store, options) {
197
197
  return {
198
198
  name: 'jeepay',
199
199
  scenes: ['pc', 'h5', 'miniapp'],
200
+ callbackVerification: 'driver',
200
201
  createOrder,
201
202
  verifyCallback,
202
203
  queryOrder,
package/dist/lakala.js CHANGED
@@ -234,6 +234,7 @@ export function createLakalaDriver(_store, options) {
234
234
  return {
235
235
  name: 'lakala',
236
236
  scenes: ['pc', 'h5', 'miniapp'],
237
+ callbackVerification: 'driver',
237
238
  createOrder,
238
239
  verifyCallback,
239
240
  queryOrder,
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
- const DEFAULT_API_KEY = 'dev-secret-0123456789abcdef';
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 ?? createMemoryStore();
24
- const apiKey = options.apiKey ?? DEFAULT_API_KEY;
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
- async function createOrder(req) {
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
- // server-side pricing: amount from business (security red line — never trust the client amount)
72
- let expected = req.amount;
73
- if (getOrderAmount) {
74
- expected = await getOrderAmount(orderId);
75
- if (expected === undefined) {
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
- // per-order limit: reject over-limit (avoids channel errors/risk control)
83
- if (config.maxAmount && expected > config.maxAmount) {
84
- return { error: 'amount_exceeds_limit', max: config.maxAmount };
85
- }
86
- // duplicate guard: an unpaid order already exists
87
- const existingOrder = store.getOrder(orderId);
88
- if (existingOrder && existingOrder.state === 'created') {
89
- const strategy = config.duplicateOrderStrategy ?? 'ask';
90
- if (strategy === 'ask') {
91
- return {
92
- kind: 'duplicate',
93
- existingOrderId: existingOrder.orderId,
94
- existingAmount: existingOrder.amount,
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
- if (strategy === 'reuse') {
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
- kind: 'ok',
100
- driver: existingOrder.driver,
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
- if (strategy === 'reject') {
107
- return { error: 'order_conflict', message: `active order exists for ${orderId}` };
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
- // 'replace': close old and create new
110
- existingOrder.state = 'closed';
111
- audit({
112
- operation: 'close',
277
+ const record = newOrder({
113
278
  orderId,
114
- from: 'created',
115
- to: 'closed',
116
- reason: 'duplicate_replace',
117
- at: Date.now(),
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: 'paid', reason: 'zero_amount', at: Date.now() });
129
- void notifyVerified?.({ orderId, state: 'paid', paidAt: record.paidAt });
130
- return { kind: 'ok', driver: driver.name, orderId, state: 'paid' };
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
- amount: expected,
139
- subject: req.subject,
140
- scene: req.scene ?? 'pc',
141
- notifyUrl: '',
142
- });
293
+ state: 'created',
294
+ ...(init ? { init } : {}),
295
+ };
143
296
  }
144
- catch (error) {
145
- alert({ level: 'error', code: 'channel_error', message: String(error), driver: driver.name });
146
- return { error: 'channel_unavailable', message: String(error) };
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 notifyVerified?.({
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 notifyVerified?.({
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 = Boolean(timestamp && nonce && signature);
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 (transition.state === 'paid' && !('idempotent' in transition && transition.idempotent)) {
349
- void notifyVerified?.({
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
  };
@@ -18,6 +18,7 @@ export function createMockDriver(store, options) {
18
18
  return {
19
19
  name,
20
20
  scenes: ['pc', 'h5'],
21
+ callbackVerification: 'manager-hmac',
21
22
  createOrder(input) {
22
23
  if (!alwaysSucceed) {
23
24
  return Promise.reject(new Error('channel_unavailable'));
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
- /** In-memory implementation (single process; use an internal mutex for concurrent safety). */
23
- export declare function createMemoryStore(): PaymentStore;
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 (single process; use an internal mutex for concurrent safety). */
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 nonces = new Set();
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.add(nonce);
53
+ nonces.set(nonce, now + retentionMs);
29
54
  return true;
30
55
  },
31
56
  getOrderByIdempotencyKey(key) {
32
- return idempotency.get(key);
57
+ const now = Date.now();
58
+ pruneExpired(now);
59
+ return idempotency.get(key)?.orderId;
33
60
  },
34
61
  setIdempotencyKey(key, orderId) {
35
- idempotency.set(key, orderId);
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;
@@ -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.1.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": ">=20.19.0"
31
+ "node": ">=22.12.0"
32
32
  },
33
33
  "bugs": {
34
34
  "url": "https://github.com/vobsjs/vobs/issues"