@vobs/payment 0.1.0 → 0.1.1

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