@vobs/payment 0.1.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.
@@ -0,0 +1,399 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * PaymentManager — payment core service layer (framework-agnostic, pure logic).
7
+ *
8
+ * Security baseline coverage (design doc §3/§5):
9
+ * - Server-side pricing (amount comes from the business's getOrderAmount; the frontend amount is only a consistency check)
10
+ * - MAX_AMOUNT per-order limit (over-limit rejected, avoids channel errors/risk control)
11
+ * - Order idempotency (idempotencyKey) + duplicate-order guard for the same business order (duplicateOrderStrategy)
12
+ * - Callback verification + time window + nonce anti-replay + callback idempotency (persist before responding)
13
+ * - Conflict freeze / paid-after-close freeze / large-amount manual review (AUDIT_THRESHOLD + confirm)
14
+ * - Operation audit (order/callback-book/freeze/confirm all logged)
15
+ * - onVerified business hook (must be idempotent) / onAlert alert hook
16
+ */
17
+ import { applyClose, applyConfirm, applyPaidCallback } from './state-machine.js';
18
+ import { createMemoryStore } from './store.js';
19
+ import { verifySignedCallback } from './verify.js';
20
+ const DEFAULT_API_KEY = 'dev-secret-0123456789abcdef';
21
+ export function createPaymentManager(options = {}) {
22
+ const config = options.config ?? {};
23
+ const store = options.store ?? createMemoryStore();
24
+ const apiKey = options.apiKey ?? DEFAULT_API_KEY;
25
+ const getOrderAmount = options.getOrderAmount;
26
+ const drivers = new Map();
27
+ const notifyVerified = options.onVerified;
28
+ const notifyAlert = options.onAlert;
29
+ const audit = (entry) => {
30
+ store.appendAudit(entry);
31
+ };
32
+ const alert = (alert) => {
33
+ notifyAlert?.(alert);
34
+ };
35
+ function driverOf(name) {
36
+ const driver = drivers.get(name);
37
+ if (!driver)
38
+ throw new Error(`driver_not_found: ${name}`);
39
+ return driver;
40
+ }
41
+ /** Create an order record (zero-amount orders skip the channel and have no init). */
42
+ function newOrder(input, driver, state) {
43
+ return {
44
+ orderId: input.orderId,
45
+ amount: input.amount,
46
+ subject: input.subject,
47
+ scene: input.scene,
48
+ driver: driver.name,
49
+ state,
50
+ createdAt: Date.now(),
51
+ };
52
+ }
53
+ async function createOrder(req) {
54
+ const orderId = req.orderId;
55
+ // ① idempotency: same idempotencyKey returns the same order
56
+ if (req.idempotencyKey) {
57
+ const existingId = store.getOrderByIdempotencyKey(req.idempotencyKey);
58
+ if (existingId) {
59
+ const existing = store.getOrder(existingId);
60
+ if (existing) {
61
+ return {
62
+ kind: 'ok',
63
+ driver: existing.driver,
64
+ orderId: existing.orderId,
65
+ state: existing.state,
66
+ ...(existing.init ? { init: existing.init } : {}),
67
+ };
68
+ }
69
+ }
70
+ }
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) {
76
+ return { error: 'order_not_found' };
77
+ }
78
+ if (req.amount !== expected) {
79
+ return { error: 'amount_mismatch', expected };
80
+ }
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
+ };
96
+ }
97
+ if (strategy === 'reuse') {
98
+ return {
99
+ kind: 'ok',
100
+ driver: existingOrder.driver,
101
+ orderId: existingOrder.orderId,
102
+ state: existingOrder.state,
103
+ ...(existingOrder.init ? { init: existingOrder.init } : {}),
104
+ };
105
+ }
106
+ if (strategy === 'reject') {
107
+ return { error: 'order_conflict', message: `active order exists for ${orderId}` };
108
+ }
109
+ // 'replace': close old and create new
110
+ existingOrder.state = 'closed';
111
+ audit({
112
+ operation: 'close',
113
+ 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();
125
+ store.saveOrder(record);
126
+ if (req.idempotencyKey)
127
+ 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({
137
+ orderId,
138
+ amount: expected,
139
+ subject: req.subject,
140
+ scene: req.scene ?? 'pc',
141
+ notifyUrl: '',
142
+ });
143
+ }
144
+ catch (error) {
145
+ alert({ level: 'error', code: 'channel_error', message: String(error), driver: driver.name });
146
+ return { error: 'channel_unavailable', message: String(error) };
147
+ }
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
+ }
162
+ function availableDrivers() {
163
+ return Array.from(drivers.values()).map((d) => ({ name: d.name, scenes: d.scenes }));
164
+ }
165
+ function getStatus(orderId) {
166
+ const order = store.getOrder(orderId);
167
+ if (!order)
168
+ return undefined;
169
+ return { state: order.state, ...(order.paidAt ? { paidAt: order.paidAt } : {}) };
170
+ }
171
+ function close(orderId) {
172
+ const order = store.getOrder(orderId);
173
+ if (!order)
174
+ return undefined;
175
+ const before = order.state;
176
+ const { state, ok } = applyClose(order.state);
177
+ if (ok) {
178
+ order.state = state;
179
+ audit({ operation: 'close', orderId, from: before, to: state, at: Date.now() });
180
+ }
181
+ return { state: order.state, ...(order.paidAt ? { paidAt: order.paidAt } : {}) };
182
+ }
183
+ function confirm(orderId) {
184
+ const order = store.getOrder(orderId);
185
+ if (!order)
186
+ return undefined;
187
+ const before = order.state;
188
+ const { state, ok } = applyConfirm(order.state);
189
+ if (ok) {
190
+ order.state = state;
191
+ order.paidAt = new Date().toISOString();
192
+ 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
+ });
199
+ }
200
+ return { state: order.state, ...(order.paidAt ? { paidAt: order.paidAt } : {}) };
201
+ }
202
+ async function syncOrderStatus(orderId) {
203
+ const order = store.getOrder(orderId);
204
+ if (!order)
205
+ return undefined;
206
+ const driver = drivers.get(order.driver);
207
+ if (!driver)
208
+ return undefined;
209
+ const status = await driver.queryOrder(orderId);
210
+ // reconciliation: channel paid & local unpaid → migrate via state machine (amount from order; large-amount audit also applies)
211
+ if (status.state === 'paid' && order.state === 'created') {
212
+ const before = order.state;
213
+ const transition = applyPaidCallback(order.state, order.amount, order.amount, config.auditThreshold);
214
+ if (transition.state !== before) {
215
+ order.state = transition.state;
216
+ if (transition.state === 'paid') {
217
+ order.paidAt = new Date().toISOString();
218
+ }
219
+ audit({
220
+ operation: 'callback',
221
+ orderId,
222
+ from: before,
223
+ to: transition.state,
224
+ reason: transition.reason ?? 'sync',
225
+ at: Date.now(),
226
+ });
227
+ 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
+ });
234
+ }
235
+ }
236
+ }
237
+ return { state: order.state, ...(order.paidAt ? { paidAt: order.paidAt } : {}) };
238
+ }
239
+ function scanExpiredOrders(ttlMs) {
240
+ const now = Date.now();
241
+ const closed = [];
242
+ for (const order of store.allOrders()) {
243
+ if (order.state === 'created' && now - order.createdAt >= ttlMs) {
244
+ const before = order.state;
245
+ const { state, ok } = applyClose(order.state);
246
+ if (ok) {
247
+ order.state = state;
248
+ audit({
249
+ operation: 'close',
250
+ orderId: order.orderId,
251
+ from: before,
252
+ to: state,
253
+ reason: 'expired',
254
+ at: now,
255
+ });
256
+ closed.push({ orderId: order.orderId, state });
257
+ }
258
+ }
259
+ }
260
+ return closed;
261
+ }
262
+ async function handleCallback(driverName, ctx) {
263
+ const driver = drivers.get(driverName);
264
+ if (!driver) {
265
+ return { ok: false, status: 404, body: { ok: false, error: 'driver_not_found' } };
266
+ }
267
+ const timestamp = ctx.headers['x-timestamp'];
268
+ const nonce = ctx.headers['x-nonce'];
269
+ const signature = ctx.headers['x-signature'];
270
+ const isGenericProtocol = Boolean(timestamp && nonce && signature);
271
+ if (isGenericProtocol) {
272
+ // generic verification protocol (X-* headers + HMAC-SHA256): for mock/self-built backends
273
+ const verified = await verifySignedCallback({
274
+ apiKey,
275
+ timestamp: timestamp,
276
+ nonce: nonce,
277
+ signature: signature,
278
+ body: ctx.body,
279
+ ...(config.timeWindowSec !== undefined ? { timeWindowSec: config.timeWindowSec } : {}),
280
+ });
281
+ if (!verified.ok) {
282
+ alert({
283
+ level: 'warn',
284
+ code: 'verify_failed',
285
+ message: verified.reason,
286
+ driver: driverName,
287
+ });
288
+ return { ok: false, status: 401, body: { ok: false, error: verified.reason } };
289
+ }
290
+ // nonce anti-replay (idempotency stops double-booking; nonce stops forged replays)
291
+ if (!store.addNonce(nonce)) {
292
+ return { ok: false, status: 401, body: { ok: false, error: 'nonce_reused' } };
293
+ }
294
+ }
295
+ // otherwise: channel self-verification path — real channels (WeChat/Alipay) use their own verification protocol (platform cert/RSA2),
296
+ // handled internally by driver.verifyCallback (verify/decrypt/anti-replay; the driver holds the store for nonce dedup).
297
+ // Parse payload:
298
+ // - generic protocol path: try JSON.parse (mock path; payload is an object)
299
+ // - channel self-verify path: raw body (WeChat callback is outer JSON; Alipay notify is form-encoded),
300
+ // parsed + verified (platform cert/RSA2) + anti-replay inside driver.verifyCallback
301
+ let payload;
302
+ if (isGenericProtocol) {
303
+ try {
304
+ payload = JSON.parse(ctx.body);
305
+ }
306
+ catch {
307
+ payload = ctx.body;
308
+ }
309
+ }
310
+ else {
311
+ payload = ctx.body;
312
+ }
313
+ const callback = driver.verifyCallback(payload, ctx.headers);
314
+ if (!callback.ok || !callback.orderId || callback.paidAmount === undefined) {
315
+ return {
316
+ ok: false,
317
+ status: 400,
318
+ body: { ok: false, error: callback.reason ?? 'bad_callback' },
319
+ };
320
+ }
321
+ const order = store.getOrder(callback.orderId);
322
+ if (!order) {
323
+ return { ok: false, status: 404, body: { ok: false, error: 'order_not_found' } };
324
+ }
325
+ // state machine (amount match / idempotency / closed→frozen / large-amount pending / paid)
326
+ const before = order.state;
327
+ const transition = applyPaidCallback(order.state, order.amount, callback.paidAmount, config.auditThreshold);
328
+ // persist first, then respond (callback response protocol)
329
+ if (transition.state !== order.state) {
330
+ order.state = transition.state;
331
+ if (transition.state === 'paid') {
332
+ order.paidAt = new Date().toISOString();
333
+ if (callback.channelTxnId)
334
+ order.channelTxnId = callback.channelTxnId;
335
+ }
336
+ if (transition.state === 'pending' && transition.reason) {
337
+ order.auditReason = transition.reason;
338
+ }
339
+ audit({
340
+ operation: transition.state === 'frozen' ? 'freeze' : 'callback',
341
+ orderId: order.orderId,
342
+ from: before,
343
+ to: transition.state,
344
+ ...(transition.reason ? { reason: transition.reason } : {}),
345
+ at: Date.now(),
346
+ });
347
+ }
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
+ });
355
+ }
356
+ if (transition.state === 'frozen') {
357
+ alert({
358
+ level: 'error',
359
+ code: 'order_frozen',
360
+ message: transition.reason ?? 'frozen',
361
+ orderId: order.orderId,
362
+ });
363
+ }
364
+ if (transition.state === 'pending') {
365
+ alert({
366
+ level: 'warn',
367
+ code: 'audit_required',
368
+ message: 'large amount pending review',
369
+ orderId: order.orderId,
370
+ });
371
+ }
372
+ const body = { ok: true, orderId: order.orderId, state: order.state };
373
+ if (transition.reason)
374
+ body['reason'] = transition.reason;
375
+ return {
376
+ ok: true,
377
+ status: 200,
378
+ body,
379
+ orderId: order.orderId,
380
+ state: order.state,
381
+ ...(transition.reason ? { reason: transition.reason } : {}),
382
+ ...(driver.successResponseText ? { text: driver.successResponseText } : {}),
383
+ };
384
+ }
385
+ return {
386
+ registerDriver(driver) {
387
+ drivers.set(driver.name, driver);
388
+ },
389
+ available: availableDrivers,
390
+ createOrder,
391
+ getStatus,
392
+ close,
393
+ confirm,
394
+ syncOrderStatus,
395
+ scanExpiredOrders,
396
+ handleCallback,
397
+ auditLog: () => store.auditLog(),
398
+ };
399
+ }
@@ -0,0 +1,22 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * Mock payment driver — for P0 end-to-end demos and integration.
7
+ *
8
+ * - `createOrder`: doesn't call a real channel; returns one-time QR pay params (simulated qrUrl).
9
+ * - `verifyCallback`: package-level verification is done by PaymentManager (generic HMAC protocol);
10
+ * the mock driver's verifyCallback only does **normalization parsing** (body → VerifiedCallback).
11
+ * - `queryOrder`: reads the store (no real channel state).
12
+ *
13
+ * Real channels (wechat_official / alipay_official) are implemented in P1, replacing this with real signing/messages.
14
+ */
15
+ import type { PaymentDriver } from './types.js';
16
+ import type { PaymentStore } from './store.js';
17
+ export interface MockDriverOptions {
18
+ readonly name?: string;
19
+ /** Simulates a channel that always succeeds (default true); set false to demo channel failure. */
20
+ readonly alwaysSucceed?: boolean;
21
+ }
22
+ export declare function createMockDriver(store: PaymentStore, options?: MockDriverOptions): PaymentDriver;
@@ -0,0 +1,58 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * Mock payment driver — for P0 end-to-end demos and integration.
7
+ *
8
+ * - `createOrder`: doesn't call a real channel; returns one-time QR pay params (simulated qrUrl).
9
+ * - `verifyCallback`: package-level verification is done by PaymentManager (generic HMAC protocol);
10
+ * the mock driver's verifyCallback only does **normalization parsing** (body → VerifiedCallback).
11
+ * - `queryOrder`: reads the store (no real channel state).
12
+ *
13
+ * Real channels (wechat_official / alipay_official) are implemented in P1, replacing this with real signing/messages.
14
+ */
15
+ export function createMockDriver(store, options) {
16
+ const name = options?.name ?? 'mock';
17
+ const alwaysSucceed = options?.alwaysSucceed ?? true;
18
+ return {
19
+ name,
20
+ scenes: ['pc', 'h5'],
21
+ createOrder(input) {
22
+ if (!alwaysSucceed) {
23
+ return Promise.reject(new Error('channel_unavailable'));
24
+ }
25
+ // mock channel: scan scenes return qr; real channels return jsapi/qr/redirect per scene
26
+ return Promise.resolve({
27
+ kind: 'qr',
28
+ qrUrl: `https://pay.example.com/qr/${input.orderId}`,
29
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
30
+ });
31
+ },
32
+ verifyCallback(payload, _headers) {
33
+ // mock normalization: parse the unified callback body (real channels verify signatures + map fields in their own drivers)
34
+ const body = payload;
35
+ const orderId = body['orderId'];
36
+ const paidAmount = body['paidAmount'];
37
+ if (typeof orderId !== 'string' || typeof paidAmount !== 'number') {
38
+ return { ok: false, reason: 'bad_payload' };
39
+ }
40
+ return {
41
+ ok: true,
42
+ orderId,
43
+ paidAmount,
44
+ ...(typeof body['channelTxnId'] === 'string' ? { channelTxnId: body['channelTxnId'] } : {}),
45
+ };
46
+ },
47
+ queryOrder(orderId) {
48
+ const order = store.getOrder(orderId);
49
+ if (!order) {
50
+ return Promise.resolve({ state: 'unknown', raw: { error: 'order_not_found' } });
51
+ }
52
+ return Promise.resolve({
53
+ state: order.state,
54
+ ...(order.paidAt ? { paidAt: order.paidAt } : {}),
55
+ });
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,41 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * Order state machine (pure functions, no I/O).
7
+ *
8
+ * Transitions (no refund path):
9
+ * created ──normal callback──▶ paid (idempotent: a paid order ignores repeated callbacks)
10
+ * created ──close───▶ closed ──callback again──▶ frozen (payment after close = money-losing anomaly, manual review)
11
+ * created ──amount mismatch──▶ frozen (conflict freeze: never auto-book)
12
+ * created ──paid ≥ auditThreshold──▶ pending (large amount, manual review)──confirm──▶ paid
13
+ * zero-amount orders are paid immediately (no channel)
14
+ */
15
+ import type { OrderState } from './types.js';
16
+ export type CallbackTransition = {
17
+ readonly state: OrderState;
18
+ readonly reason?: string;
19
+ } | {
20
+ readonly state: 'paid';
21
+ readonly idempotent: boolean;
22
+ readonly reason?: string;
23
+ };
24
+ /**
25
+ * Apply a payment callback to an order.
26
+ * @param orderState current state
27
+ * @param orderAmount order amount (fen)
28
+ * @param paidAmount channel-paid amount (fen)
29
+ * @param auditThreshold large-amount audit threshold (fen); absent/0 = no audit
30
+ */
31
+ export declare function applyPaidCallback(orderState: OrderState, orderAmount: number, paidAmount: number, auditThreshold?: number): CallbackTransition;
32
+ /** Approve a large amount: only pending (audit) → paid. */
33
+ export declare function applyConfirm(orderState: OrderState): {
34
+ state: OrderState;
35
+ ok: boolean;
36
+ };
37
+ /** Close on timeout/cancel: only created → closed. */
38
+ export declare function applyClose(orderState: OrderState): {
39
+ state: OrderState;
40
+ ok: boolean;
41
+ };
@@ -0,0 +1,60 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * Order state machine (pure functions, no I/O).
7
+ *
8
+ * Transitions (no refund path):
9
+ * created ──normal callback──▶ paid (idempotent: a paid order ignores repeated callbacks)
10
+ * created ──close───▶ closed ──callback again──▶ frozen (payment after close = money-losing anomaly, manual review)
11
+ * created ──amount mismatch──▶ frozen (conflict freeze: never auto-book)
12
+ * created ──paid ≥ auditThreshold──▶ pending (large amount, manual review)──confirm──▶ paid
13
+ * zero-amount orders are paid immediately (no channel)
14
+ */
15
+ /**
16
+ * Apply a payment callback to an order.
17
+ * @param orderState current state
18
+ * @param orderAmount order amount (fen)
19
+ * @param paidAmount channel-paid amount (fen)
20
+ * @param auditThreshold large-amount audit threshold (fen); absent/0 = no audit
21
+ */
22
+ export function applyPaidCallback(orderState, orderAmount, paidAmount, auditThreshold) {
23
+ // freeze on conflict: paid ≠ order amount (security red line, never auto-book; discount net amounts are judged by the channel, differences go through amountTolerance evaluated by the business)
24
+ if (paidAmount !== orderAmount) {
25
+ return { state: 'frozen', reason: 'amount_mismatch' };
26
+ }
27
+ switch (orderState) {
28
+ case 'paid':
29
+ // idempotent: don't book twice (callback retry/redelivery)
30
+ return { state: 'paid', idempotent: true };
31
+ case 'closed':
32
+ // payment after close → anomaly, manual review
33
+ return { state: 'frozen', reason: 'paid_after_close' };
34
+ case 'frozen':
35
+ return { state: 'frozen' };
36
+ case 'pending':
37
+ return { state: 'pending' };
38
+ default: {
39
+ // created: large amount awaits manual audit (pending = 'unconfirmed': channel delay or audit)
40
+ if (auditThreshold && auditThreshold > 0 && paidAmount >= auditThreshold) {
41
+ return { state: 'pending', reason: 'audit' };
42
+ }
43
+ return { state: 'paid' };
44
+ }
45
+ }
46
+ }
47
+ /** Approve a large amount: only pending (audit) → paid. */
48
+ export function applyConfirm(orderState) {
49
+ if (orderState === 'pending') {
50
+ return { state: 'paid', ok: true };
51
+ }
52
+ return { state: orderState, ok: false };
53
+ }
54
+ /** Close on timeout/cancel: only created → closed. */
55
+ export function applyClose(orderState) {
56
+ if (orderState === 'created') {
57
+ return { state: 'closed', ok: true };
58
+ }
59
+ return { state: orderState, ok: false };
60
+ }
@@ -0,0 +1,23 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * Order storage abstraction (P0 in-memory; for a production DB see the design doc's production checklist:
7
+ * unique constraints back idempotency, row locks/CAS prevent concurrent double-writes, idempotency records and nonces with TTL).
8
+ */
9
+ import type { AuditEntry, OrderRecord } from './types.js';
10
+ export interface PaymentStore {
11
+ getOrder(orderId: string): OrderRecord | undefined;
12
+ saveOrder(order: OrderRecord): void;
13
+ /** Iterate all orders (for expiry scans; production implementations should paginate/index). */
14
+ allOrders(): readonly OrderRecord[];
15
+ /** nonce dedup (anti-replay): returns true on first use. */
16
+ addNonce(nonce: string): boolean;
17
+ getOrderByIdempotencyKey(key: string): string | undefined;
18
+ setIdempotencyKey(key: string, orderId: string): void;
19
+ appendAudit(entry: AuditEntry): void;
20
+ auditLog(): readonly AuditEntry[];
21
+ }
22
+ /** In-memory implementation (single process; use an internal mutex for concurrent safety). */
23
+ export declare function createMemoryStore(): PaymentStore;
package/dist/store.js ADDED
@@ -0,0 +1,44 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * Order storage abstraction (P0 in-memory; for a production DB see the design doc's production checklist:
7
+ * unique constraints back idempotency, row locks/CAS prevent concurrent double-writes, idempotency records and nonces with TTL).
8
+ */
9
+ /** In-memory implementation (single process; use an internal mutex for concurrent safety). */
10
+ export function createMemoryStore() {
11
+ const orders = new Map();
12
+ const nonces = new Set();
13
+ const idempotency = new Map();
14
+ const audit = [];
15
+ return {
16
+ getOrder(orderId) {
17
+ return orders.get(orderId);
18
+ },
19
+ saveOrder(order) {
20
+ orders.set(order.orderId, order);
21
+ },
22
+ allOrders() {
23
+ return Array.from(orders.values());
24
+ },
25
+ addNonce(nonce) {
26
+ if (nonces.has(nonce))
27
+ return false;
28
+ nonces.add(nonce);
29
+ return true;
30
+ },
31
+ getOrderByIdempotencyKey(key) {
32
+ return idempotency.get(key);
33
+ },
34
+ setIdempotencyKey(key, orderId) {
35
+ idempotency.set(key, orderId);
36
+ },
37
+ appendAudit(entry) {
38
+ audit.push(entry);
39
+ },
40
+ auditLog() {
41
+ return audit;
42
+ },
43
+ };
44
+ }