@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.
@@ -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,10 +27,19 @@ export interface HttpRequestContext {
27
27
  readonly headers?: Readonly<Record<string, string | undefined>>;
28
28
  readonly body?: string;
29
29
  }
30
- type RouteResult = HttpResponse;
31
- /** Create a dispatch function (pure logic, no framework dependency). */
30
+ export type PaymentProtectedRoute = 'status' | 'close' | 'confirm';
32
31
  export interface PaymentHttpDispatchOptions {
33
- readonly authorize?: (action: 'close' | 'confirm', orderId: string, context: HttpRequestContext) => boolean | Promise<boolean>;
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 (!req || typeof req !== 'object' || Array.isArray(req)) {
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 isAuthorized('close', orderId, ctx))) {
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 isAuthorized('confirm', orderId, ctx))) {
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, { authorize: requireAdmin })
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, { authorize: requireAdmin })
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
@@ -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
@@ -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' | 'configuration_error' | 'bad_request';
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
- /** Build the public callback URL for a channel driver. Required for non-zero-amount orders. */
34
- readonly getNotifyUrl?: (driverName: string) => string;
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 ?? createMemoryStore();
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 orderLocks = new Map();
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
- async function withOrderLock(orderId, operation) {
55
- const previous = orderLocks.get(orderId);
56
- let release;
57
- const current = new Promise((resolve) => {
58
- release = resolve;
59
- });
60
- orderLocks.set(orderId, current);
61
- await previous;
62
- try {
63
- return await operation();
135
+ function createOrder(req) {
136
+ if (!isValidCreateOrderRequest(req)) {
137
+ return Promise.resolve({ error: 'bad_request', message: 'Invalid payment order request' });
64
138
  }
65
- finally {
66
- release?.();
67
- if (orderLocks.get(orderId) === current)
68
- orderLocks.delete(orderId);
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 createOrder(req) {
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 (!orderId || !req.subject || !Number.isSafeInteger(req.amount) || req.amount < 0) {
77
- return {
78
- error: 'bad_request',
79
- message: 'orderId, subject, and a non-negative integer amount are required',
80
- };
81
- }
82
- if (!getOrderAmount) {
83
- return { error: 'configuration_error', message: 'getOrderAmount is required' };
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
- if (existingOrder && existingOrder.state === 'created') {
122
- const strategy = req.duplicateStrategy ?? config.duplicateOrderStrategy ?? 'ask';
123
- if (strategy === 'ask') {
124
- return {
125
- kind: 'duplicate',
126
- existingOrderId: existingOrder.orderId,
127
- existingAmount: existingOrder.amount,
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
- if (strategy === 'reuse') {
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
- kind: 'ok',
133
- driver: existingOrder.driver,
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
- if (strategy === 'reject') {
140
- 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
+ });
141
267
  }
142
- // 'replace': close old and create new
143
- existingOrder.state = 'closed';
144
- audit({
145
- operation: 'close',
146
- orderId,
147
- from: 'created',
148
- to: 'closed',
149
- reason: 'duplicate_replace',
150
- at: Date.now(),
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
- catch (error) {
182
- alert({ level: 'error', code: 'channel_error', message: String(error), driver: driver.name });
183
- return { error: 'channel_unavailable', message: String(error) };
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 notifyVerified?.({
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 notifyVerified?.({
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 = Boolean(timestamp && nonce && signature);
404
+ const isGenericProtocol = driver.callbackVerification !== 'driver';
308
405
  if (isGenericProtocol) {
309
- if (!apiKey) {
310
- return { ok: false, status: 500, body: { ok: false, error: 'api_key_not_configured' } };
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 (transition.state === 'paid' && !('idempotent' in transition && transition.idempotent)) {
389
- void notifyVerified?.({
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
  };
@@ -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 {
@@ -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;
@@ -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.1",
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"