paybridge 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/circuit-breaker.d.ts +2 -0
- package/dist/circuit-breaker.js +9 -0
- package/dist/crypto/router.d.ts +9 -0
- package/dist/crypto/router.js +143 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -1
- package/dist/ledger.d.ts +36 -0
- package/dist/ledger.js +32 -0
- package/dist/router-events.d.ts +16 -0
- package/dist/router-events.js +11 -0
- package/dist/router.d.ts +9 -0
- package/dist/router.js +258 -6
- package/dist/stores/redis-ledger.d.ts +8 -0
- package/dist/stores/redis-ledger.js +52 -0
- package/dist/stores/redis.d.ts +6 -0
- package/dist/tracer.d.ts +9 -0
- package/dist/tracer.js +10 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Supports pluggable storage for multi-instance deployments.
|
|
4
4
|
* Not atomic across processes — eventual consistency accepted.
|
|
5
5
|
*/
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
6
7
|
import { CircuitBreakerStore } from './circuit-breaker-store';
|
|
7
8
|
export declare enum CircuitState {
|
|
8
9
|
CLOSED = "CLOSED",
|
|
@@ -15,6 +16,7 @@ export interface CircuitBreakerConfig {
|
|
|
15
16
|
store?: CircuitBreakerStore;
|
|
16
17
|
}
|
|
17
18
|
export declare class CircuitBreaker {
|
|
19
|
+
readonly events: EventEmitter<any>;
|
|
18
20
|
private readonly key;
|
|
19
21
|
private readonly failureThreshold;
|
|
20
22
|
private readonly resetTimeoutMs;
|
package/dist/circuit-breaker.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.CircuitBreaker = exports.CircuitState = void 0;
|
|
9
|
+
const node_events_1 = require("node:events");
|
|
9
10
|
const circuit_breaker_store_1 = require("./circuit-breaker-store");
|
|
10
11
|
var CircuitState;
|
|
11
12
|
(function (CircuitState) {
|
|
@@ -15,6 +16,7 @@ var CircuitState;
|
|
|
15
16
|
})(CircuitState || (exports.CircuitState = CircuitState = {}));
|
|
16
17
|
class CircuitBreaker {
|
|
17
18
|
constructor(key = 'default', config = {}) {
|
|
19
|
+
this.events = new node_events_1.EventEmitter();
|
|
18
20
|
this.key = key;
|
|
19
21
|
this.failureThreshold = config.failureThreshold ?? 5;
|
|
20
22
|
this.resetTimeoutMs = config.resetTimeoutMs ?? 30000;
|
|
@@ -26,6 +28,7 @@ class CircuitBreaker {
|
|
|
26
28
|
if (Date.now() >= snapshot.nextAttemptTime) {
|
|
27
29
|
snapshot.state = CircuitState.HALF_OPEN;
|
|
28
30
|
await this.saveSnapshot(snapshot);
|
|
31
|
+
this.events.emit('half_opened', this.key);
|
|
29
32
|
return false;
|
|
30
33
|
}
|
|
31
34
|
return true;
|
|
@@ -33,12 +36,16 @@ class CircuitBreaker {
|
|
|
33
36
|
return false;
|
|
34
37
|
}
|
|
35
38
|
async recordSuccess() {
|
|
39
|
+
const prevSnapshot = await this.getSnapshot();
|
|
36
40
|
const snapshot = {
|
|
37
41
|
state: CircuitState.CLOSED,
|
|
38
42
|
failureCount: 0,
|
|
39
43
|
nextAttemptTime: 0,
|
|
40
44
|
};
|
|
41
45
|
await this.saveSnapshot(snapshot);
|
|
46
|
+
if (prevSnapshot.state !== CircuitState.CLOSED) {
|
|
47
|
+
this.events.emit('closed', this.key);
|
|
48
|
+
}
|
|
42
49
|
}
|
|
43
50
|
async recordFailure() {
|
|
44
51
|
const snapshot = await this.getSnapshot();
|
|
@@ -47,11 +54,13 @@ class CircuitBreaker {
|
|
|
47
54
|
snapshot.state = CircuitState.OPEN;
|
|
48
55
|
snapshot.nextAttemptTime = Date.now() + this.resetTimeoutMs;
|
|
49
56
|
await this.saveSnapshot(snapshot, this.resetTimeoutMs + 5000);
|
|
57
|
+
this.events.emit('opened', this.key);
|
|
50
58
|
}
|
|
51
59
|
else if (snapshot.failureCount >= this.failureThreshold) {
|
|
52
60
|
snapshot.state = CircuitState.OPEN;
|
|
53
61
|
snapshot.nextAttemptTime = Date.now() + this.resetTimeoutMs;
|
|
54
62
|
await this.saveSnapshot(snapshot, this.resetTimeoutMs + 5000);
|
|
63
|
+
this.events.emit('opened', this.key);
|
|
55
64
|
}
|
|
56
65
|
else {
|
|
57
66
|
await this.saveSnapshot(snapshot);
|
package/dist/crypto/router.d.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { CryptoRamp } from './index';
|
|
5
5
|
import { OnRampParams, OffRampParams, RampQuote, RampResult } from './types';
|
|
6
|
+
import { RouterEventEmitter } from '../router-events';
|
|
7
|
+
import type { LedgerStore } from '../ledger';
|
|
8
|
+
import type { TracerLike } from '../tracer';
|
|
6
9
|
export interface CryptoRampRouterConfig {
|
|
7
10
|
providers: Array<{
|
|
8
11
|
provider: CryptoRamp;
|
|
@@ -17,13 +20,18 @@ export interface CryptoRampRouterConfig {
|
|
|
17
20
|
allowExperimental?: boolean;
|
|
18
21
|
circuitBreakerStore?: import('../circuit-breaker-store').CircuitBreakerStore;
|
|
19
22
|
idempotencyStore?: import('../webhook-idempotency-store').IdempotencyStore;
|
|
23
|
+
ledger?: LedgerStore;
|
|
24
|
+
tracer?: TracerLike;
|
|
20
25
|
}
|
|
21
26
|
export declare class CryptoRampRouter {
|
|
27
|
+
readonly events: RouterEventEmitter;
|
|
22
28
|
private providers;
|
|
23
29
|
private strategy;
|
|
24
30
|
private fallback;
|
|
25
31
|
private allowExperimental;
|
|
26
32
|
private circuitBreakers;
|
|
33
|
+
private ledger?;
|
|
34
|
+
private tracer;
|
|
27
35
|
private rrIndex;
|
|
28
36
|
private config;
|
|
29
37
|
constructor(config: CryptoRampRouterConfig);
|
|
@@ -34,4 +42,5 @@ export declare class CryptoRampRouter {
|
|
|
34
42
|
private filterProviders;
|
|
35
43
|
private orderProviders;
|
|
36
44
|
private sleep;
|
|
45
|
+
private recordLedgerEntry;
|
|
37
46
|
}
|
package/dist/crypto/router.js
CHANGED
|
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.CryptoRampRouter = void 0;
|
|
7
7
|
const routing_types_1 = require("../routing-types");
|
|
8
8
|
const circuit_breaker_1 = require("../circuit-breaker");
|
|
9
|
+
const router_events_1 = require("../router-events");
|
|
10
|
+
const tracer_1 = require("../tracer");
|
|
9
11
|
function sanitizeErrorMessage(msg) {
|
|
10
12
|
if (!msg)
|
|
11
13
|
return 'unknown error';
|
|
@@ -16,6 +18,7 @@ function sanitizeErrorMessage(msg) {
|
|
|
16
18
|
}
|
|
17
19
|
class CryptoRampRouter {
|
|
18
20
|
constructor(config) {
|
|
21
|
+
this.events = new router_events_1.RouterEventEmitter();
|
|
19
22
|
this.rrIndex = 0;
|
|
20
23
|
this.config = config;
|
|
21
24
|
this.providers = config.providers.map(p => ({
|
|
@@ -29,12 +32,36 @@ class CryptoRampRouter {
|
|
|
29
32
|
retryDelayMs: config.fallback?.retryDelayMs ?? 250,
|
|
30
33
|
};
|
|
31
34
|
this.allowExperimental = config.allowExperimental ?? false;
|
|
35
|
+
this.ledger = config.ledger;
|
|
36
|
+
this.tracer = config.tracer ?? tracer_1.noopTracer;
|
|
32
37
|
this.circuitBreakers = new Map();
|
|
33
38
|
for (const p of this.providers) {
|
|
34
39
|
const name = p.instance.getProviderName();
|
|
35
|
-
|
|
40
|
+
const breaker = new circuit_breaker_1.CircuitBreaker(name, {
|
|
36
41
|
store: config.circuitBreakerStore,
|
|
37
|
-
})
|
|
42
|
+
});
|
|
43
|
+
breaker.events.on('opened', (key) => {
|
|
44
|
+
this.events.emitEvent({
|
|
45
|
+
type: 'circuit.opened',
|
|
46
|
+
provider: key,
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
breaker.events.on('half_opened', (key) => {
|
|
51
|
+
this.events.emitEvent({
|
|
52
|
+
type: 'circuit.half_opened',
|
|
53
|
+
provider: key,
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
breaker.events.on('closed', (key) => {
|
|
58
|
+
this.events.emitEvent({
|
|
59
|
+
type: 'circuit.closed',
|
|
60
|
+
provider: key,
|
|
61
|
+
timestamp: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
this.circuitBreakers.set(name, breaker);
|
|
38
65
|
}
|
|
39
66
|
}
|
|
40
67
|
async getQuote(direction, fiatAmount, fiatCurrency, cryptoAsset, network) {
|
|
@@ -114,6 +141,13 @@ class CryptoRampRouter {
|
|
|
114
141
|
continue;
|
|
115
142
|
}
|
|
116
143
|
const startTime = Date.now();
|
|
144
|
+
this.events.emitEvent({
|
|
145
|
+
type: 'attempt.start',
|
|
146
|
+
provider: providerName,
|
|
147
|
+
operation: 'createOnRamp',
|
|
148
|
+
attempt: attempts.length + 1,
|
|
149
|
+
timestamp: new Date().toISOString(),
|
|
150
|
+
});
|
|
117
151
|
try {
|
|
118
152
|
const result = await providerMeta.instance.createOnRamp(params);
|
|
119
153
|
const latencyMs = Date.now() - startTime;
|
|
@@ -124,6 +158,32 @@ class CryptoRampRouter {
|
|
|
124
158
|
status: 'success',
|
|
125
159
|
latencyMs,
|
|
126
160
|
});
|
|
161
|
+
this.events.emitEvent({
|
|
162
|
+
type: 'attempt.success',
|
|
163
|
+
provider: providerName,
|
|
164
|
+
operation: 'createOnRamp',
|
|
165
|
+
durationMs: latencyMs,
|
|
166
|
+
attempt: attempts.length,
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
});
|
|
169
|
+
this.events.emitEvent({
|
|
170
|
+
type: 'request.success',
|
|
171
|
+
provider: providerName,
|
|
172
|
+
operation: 'createOnRamp',
|
|
173
|
+
durationMs: latencyMs,
|
|
174
|
+
timestamp: new Date().toISOString(),
|
|
175
|
+
});
|
|
176
|
+
await this.recordLedgerEntry({
|
|
177
|
+
id: `${providerName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
178
|
+
timestamp: new Date().toISOString(),
|
|
179
|
+
operation: 'createOnRamp',
|
|
180
|
+
provider: providerName,
|
|
181
|
+
providerId: result.id,
|
|
182
|
+
status: 'success',
|
|
183
|
+
amount: params.fiatAmount,
|
|
184
|
+
currency: params.fiatCurrency,
|
|
185
|
+
durationMs: latencyMs,
|
|
186
|
+
});
|
|
127
187
|
const routingMeta = {
|
|
128
188
|
attempts,
|
|
129
189
|
chosenProvider: providerName,
|
|
@@ -146,12 +206,28 @@ class CryptoRampRouter {
|
|
|
146
206
|
errorMessage: sanitizeErrorMessage(error.message),
|
|
147
207
|
latencyMs,
|
|
148
208
|
});
|
|
209
|
+
this.events.emitEvent({
|
|
210
|
+
type: 'attempt.failure',
|
|
211
|
+
provider: providerName,
|
|
212
|
+
operation: 'createOnRamp',
|
|
213
|
+
durationMs: latencyMs,
|
|
214
|
+
errorCode: error.code,
|
|
215
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
216
|
+
attempt: attempts.length,
|
|
217
|
+
timestamp: new Date().toISOString(),
|
|
218
|
+
});
|
|
149
219
|
if (!this.fallback.enabled || attempts.length >= this.fallback.maxAttempts) {
|
|
150
220
|
break;
|
|
151
221
|
}
|
|
152
222
|
await this.sleep(this.fallback.retryDelayMs);
|
|
153
223
|
}
|
|
154
224
|
}
|
|
225
|
+
this.events.emitEvent({
|
|
226
|
+
type: 'request.failure',
|
|
227
|
+
operation: 'createOnRamp',
|
|
228
|
+
errorMessage: lastError?.message || 'All providers failed',
|
|
229
|
+
timestamp: new Date().toISOString(),
|
|
230
|
+
});
|
|
155
231
|
throw new routing_types_1.RoutingError(`All providers failed: ${lastError?.message || 'Unknown error'}`, attempts);
|
|
156
232
|
}
|
|
157
233
|
async createOffRamp(params) {
|
|
@@ -178,6 +254,13 @@ class CryptoRampRouter {
|
|
|
178
254
|
continue;
|
|
179
255
|
}
|
|
180
256
|
const startTime = Date.now();
|
|
257
|
+
this.events.emitEvent({
|
|
258
|
+
type: 'attempt.start',
|
|
259
|
+
provider: providerName,
|
|
260
|
+
operation: 'createOffRamp',
|
|
261
|
+
attempt: attempts.length + 1,
|
|
262
|
+
timestamp: new Date().toISOString(),
|
|
263
|
+
});
|
|
181
264
|
try {
|
|
182
265
|
const result = await providerMeta.instance.createOffRamp(params);
|
|
183
266
|
const latencyMs = Date.now() - startTime;
|
|
@@ -188,6 +271,32 @@ class CryptoRampRouter {
|
|
|
188
271
|
status: 'success',
|
|
189
272
|
latencyMs,
|
|
190
273
|
});
|
|
274
|
+
this.events.emitEvent({
|
|
275
|
+
type: 'attempt.success',
|
|
276
|
+
provider: providerName,
|
|
277
|
+
operation: 'createOffRamp',
|
|
278
|
+
durationMs: latencyMs,
|
|
279
|
+
attempt: attempts.length,
|
|
280
|
+
timestamp: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
this.events.emitEvent({
|
|
283
|
+
type: 'request.success',
|
|
284
|
+
provider: providerName,
|
|
285
|
+
operation: 'createOffRamp',
|
|
286
|
+
durationMs: latencyMs,
|
|
287
|
+
timestamp: new Date().toISOString(),
|
|
288
|
+
});
|
|
289
|
+
await this.recordLedgerEntry({
|
|
290
|
+
id: `${providerName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
291
|
+
timestamp: new Date().toISOString(),
|
|
292
|
+
operation: 'createOffRamp',
|
|
293
|
+
provider: providerName,
|
|
294
|
+
providerId: result.id,
|
|
295
|
+
status: 'success',
|
|
296
|
+
amount: params.cryptoAmount,
|
|
297
|
+
currency: params.fiatCurrency,
|
|
298
|
+
durationMs: latencyMs,
|
|
299
|
+
});
|
|
191
300
|
const routingMeta = {
|
|
192
301
|
attempts,
|
|
193
302
|
chosenProvider: providerName,
|
|
@@ -210,12 +319,28 @@ class CryptoRampRouter {
|
|
|
210
319
|
errorMessage: sanitizeErrorMessage(error.message),
|
|
211
320
|
latencyMs,
|
|
212
321
|
});
|
|
322
|
+
this.events.emitEvent({
|
|
323
|
+
type: 'attempt.failure',
|
|
324
|
+
provider: providerName,
|
|
325
|
+
operation: 'createOffRamp',
|
|
326
|
+
durationMs: latencyMs,
|
|
327
|
+
errorCode: error.code,
|
|
328
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
329
|
+
attempt: attempts.length,
|
|
330
|
+
timestamp: new Date().toISOString(),
|
|
331
|
+
});
|
|
213
332
|
if (!this.fallback.enabled || attempts.length >= this.fallback.maxAttempts) {
|
|
214
333
|
break;
|
|
215
334
|
}
|
|
216
335
|
await this.sleep(this.fallback.retryDelayMs);
|
|
217
336
|
}
|
|
218
337
|
}
|
|
338
|
+
this.events.emitEvent({
|
|
339
|
+
type: 'request.failure',
|
|
340
|
+
operation: 'createOffRamp',
|
|
341
|
+
errorMessage: lastError?.message || 'All providers failed',
|
|
342
|
+
timestamp: new Date().toISOString(),
|
|
343
|
+
});
|
|
219
344
|
throw new routing_types_1.RoutingError(`All providers failed: ${lastError?.message || 'Unknown error'}`, attempts);
|
|
220
345
|
}
|
|
221
346
|
async getRamp(id, provider) {
|
|
@@ -291,5 +416,21 @@ class CryptoRampRouter {
|
|
|
291
416
|
sleep(ms) {
|
|
292
417
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
293
418
|
}
|
|
419
|
+
async recordLedgerEntry(entry) {
|
|
420
|
+
if (!this.ledger)
|
|
421
|
+
return;
|
|
422
|
+
try {
|
|
423
|
+
await this.ledger.append(entry);
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
this.events.emitEvent({
|
|
427
|
+
type: 'attempt.failure',
|
|
428
|
+
operation: entry.operation,
|
|
429
|
+
provider: entry.provider,
|
|
430
|
+
errorMessage: 'Ledger write failed',
|
|
431
|
+
timestamp: new Date().toISOString(),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
294
435
|
}
|
|
295
436
|
exports.CryptoRampRouter = CryptoRampRouter;
|
package/dist/index.d.ts
CHANGED
|
@@ -16,8 +16,12 @@ export * from './strategies';
|
|
|
16
16
|
export * from './router';
|
|
17
17
|
export * from './crypto';
|
|
18
18
|
export * from './webhook-idempotency-store';
|
|
19
|
+
export * from './router-events';
|
|
20
|
+
export * from './ledger';
|
|
21
|
+
export * from './tracer';
|
|
19
22
|
export { createRedisCircuitBreakerStore, type RedisLike, type RedisStoreOptions } from './stores/redis';
|
|
20
23
|
export { createRedisIdempotencyStore, type RedisIdempotencyStoreOptions } from './stores/redis-idempotency';
|
|
24
|
+
export { createRedisLedgerStore, type RedisLedgerStoreOptions } from './stores/redis-ledger';
|
|
21
25
|
export declare class PayBridge {
|
|
22
26
|
readonly provider: PaymentProvider;
|
|
23
27
|
constructor(config: PayBridgeConfig);
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
20
20
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
21
21
|
};
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
-
exports.PayBridge = exports.createRedisIdempotencyStore = exports.createRedisCircuitBreakerStore = void 0;
|
|
23
|
+
exports.PayBridge = exports.createRedisLedgerStore = exports.createRedisIdempotencyStore = exports.createRedisCircuitBreakerStore = void 0;
|
|
24
24
|
const softycomp_1 = require("./providers/softycomp");
|
|
25
25
|
const yoco_1 = require("./providers/yoco");
|
|
26
26
|
const ozow_1 = require("./providers/ozow");
|
|
@@ -39,10 +39,15 @@ __exportStar(require("./strategies"), exports);
|
|
|
39
39
|
__exportStar(require("./router"), exports);
|
|
40
40
|
__exportStar(require("./crypto"), exports);
|
|
41
41
|
__exportStar(require("./webhook-idempotency-store"), exports);
|
|
42
|
+
__exportStar(require("./router-events"), exports);
|
|
43
|
+
__exportStar(require("./ledger"), exports);
|
|
44
|
+
__exportStar(require("./tracer"), exports);
|
|
42
45
|
var redis_1 = require("./stores/redis");
|
|
43
46
|
Object.defineProperty(exports, "createRedisCircuitBreakerStore", { enumerable: true, get: function () { return redis_1.createRedisCircuitBreakerStore; } });
|
|
44
47
|
var redis_idempotency_1 = require("./stores/redis-idempotency");
|
|
45
48
|
Object.defineProperty(exports, "createRedisIdempotencyStore", { enumerable: true, get: function () { return redis_idempotency_1.createRedisIdempotencyStore; } });
|
|
49
|
+
var redis_ledger_1 = require("./stores/redis-ledger");
|
|
50
|
+
Object.defineProperty(exports, "createRedisLedgerStore", { enumerable: true, get: function () { return redis_ledger_1.createRedisLedgerStore; } });
|
|
46
51
|
class PayBridge {
|
|
47
52
|
constructor(config) {
|
|
48
53
|
this.provider = this.createProvider(config);
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface LedgerEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
timestamp: string;
|
|
4
|
+
operation: 'createPayment' | 'createSubscription' | 'getPayment' | 'refund' | 'createOnRamp' | 'createOffRamp' | 'getRamp' | 'parseWebhook';
|
|
5
|
+
provider: string;
|
|
6
|
+
reference?: string;
|
|
7
|
+
providerId?: string;
|
|
8
|
+
status: 'attempted' | 'success' | 'failed' | 'rate_limited' | 'timeout';
|
|
9
|
+
amount?: number;
|
|
10
|
+
currency?: string;
|
|
11
|
+
durationMs?: number;
|
|
12
|
+
errorCode?: string;
|
|
13
|
+
errorMessage?: string;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface LedgerQuery {
|
|
17
|
+
reference?: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
status?: LedgerEntry['status'];
|
|
20
|
+
fromTimestamp?: string;
|
|
21
|
+
toTimestamp?: string;
|
|
22
|
+
limit?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface LedgerStore {
|
|
25
|
+
append(entry: LedgerEntry): Promise<void>;
|
|
26
|
+
query(filter: LedgerQuery): Promise<LedgerEntry[]>;
|
|
27
|
+
}
|
|
28
|
+
export declare class InMemoryLedgerStore implements LedgerStore {
|
|
29
|
+
private entries;
|
|
30
|
+
private maxSize;
|
|
31
|
+
constructor(opts?: {
|
|
32
|
+
maxSize?: number;
|
|
33
|
+
});
|
|
34
|
+
append(entry: LedgerEntry): Promise<void>;
|
|
35
|
+
query(filter: LedgerQuery): Promise<LedgerEntry[]>;
|
|
36
|
+
}
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InMemoryLedgerStore = void 0;
|
|
4
|
+
class InMemoryLedgerStore {
|
|
5
|
+
constructor(opts = {}) {
|
|
6
|
+
this.entries = [];
|
|
7
|
+
this.maxSize = opts.maxSize ?? 10000;
|
|
8
|
+
}
|
|
9
|
+
async append(entry) {
|
|
10
|
+
this.entries.push(entry);
|
|
11
|
+
if (this.entries.length > this.maxSize) {
|
|
12
|
+
this.entries.splice(0, this.entries.length - this.maxSize);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async query(filter) {
|
|
16
|
+
let results = this.entries;
|
|
17
|
+
if (filter.reference)
|
|
18
|
+
results = results.filter(e => e.reference === filter.reference);
|
|
19
|
+
if (filter.provider)
|
|
20
|
+
results = results.filter(e => e.provider === filter.provider);
|
|
21
|
+
if (filter.status)
|
|
22
|
+
results = results.filter(e => e.status === filter.status);
|
|
23
|
+
if (filter.fromTimestamp)
|
|
24
|
+
results = results.filter(e => e.timestamp >= filter.fromTimestamp);
|
|
25
|
+
if (filter.toTimestamp)
|
|
26
|
+
results = results.filter(e => e.timestamp <= filter.toTimestamp);
|
|
27
|
+
if (filter.limit)
|
|
28
|
+
results = results.slice(0, filter.limit);
|
|
29
|
+
return [...results];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.InMemoryLedgerStore = InMemoryLedgerStore;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
export type RouterEventType = 'attempt.start' | 'attempt.success' | 'attempt.failure' | 'attempt.rate_limited' | 'attempt.timeout' | 'circuit.opened' | 'circuit.half_opened' | 'circuit.closed' | 'webhook.duplicate' | 'request.success' | 'request.failure';
|
|
3
|
+
export interface RouterEvent {
|
|
4
|
+
type: RouterEventType;
|
|
5
|
+
provider?: string;
|
|
6
|
+
operation?: 'createPayment' | 'createSubscription' | 'getPayment' | 'refund' | 'parseWebhook' | 'createOnRamp' | 'createOffRamp' | 'getQuote' | 'getRamp';
|
|
7
|
+
reference?: string;
|
|
8
|
+
durationMs?: number;
|
|
9
|
+
errorCode?: string;
|
|
10
|
+
errorMessage?: string;
|
|
11
|
+
attempt?: number;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class RouterEventEmitter extends EventEmitter {
|
|
15
|
+
emitEvent(event: RouterEvent): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RouterEventEmitter = void 0;
|
|
4
|
+
const node_events_1 = require("node:events");
|
|
5
|
+
class RouterEventEmitter extends node_events_1.EventEmitter {
|
|
6
|
+
emitEvent(event) {
|
|
7
|
+
this.emit(event.type, event);
|
|
8
|
+
this.emit('*', event);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
exports.RouterEventEmitter = RouterEventEmitter;
|
package/dist/router.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { PayBridge } from './index';
|
|
|
5
5
|
import { CreatePaymentParams, PaymentResult, CreateSubscriptionParams, SubscriptionResult, RefundParams, RefundResult, WebhookEvent, Provider } from './types';
|
|
6
6
|
import { RoutingStrategy, FallbackConfig } from './routing-types';
|
|
7
7
|
import type { IdempotencyStore } from './webhook-idempotency-store';
|
|
8
|
+
import { RouterEventEmitter } from './router-events';
|
|
9
|
+
import type { LedgerStore } from './ledger';
|
|
10
|
+
import type { TracerLike } from './tracer';
|
|
8
11
|
export declare class WebhookDuplicateError extends Error {
|
|
9
12
|
readonly name = "WebhookDuplicateError";
|
|
10
13
|
readonly eventId: string;
|
|
@@ -21,13 +24,18 @@ export interface PayBridgeRouterConfig {
|
|
|
21
24
|
fallback?: FallbackConfig;
|
|
22
25
|
circuitBreakerStore?: import('./circuit-breaker-store').CircuitBreakerStore;
|
|
23
26
|
idempotencyStore?: IdempotencyStore;
|
|
27
|
+
ledger?: LedgerStore;
|
|
28
|
+
tracer?: TracerLike;
|
|
24
29
|
}
|
|
25
30
|
export declare class PayBridgeRouter {
|
|
31
|
+
readonly events: RouterEventEmitter;
|
|
26
32
|
private providers;
|
|
27
33
|
private strategy;
|
|
28
34
|
private fallback;
|
|
29
35
|
private circuitBreakers;
|
|
30
36
|
private idempotencyStore?;
|
|
37
|
+
private ledger?;
|
|
38
|
+
private tracer;
|
|
31
39
|
private rrIndex;
|
|
32
40
|
private config;
|
|
33
41
|
constructor(config: PayBridgeRouterConfig);
|
|
@@ -39,4 +47,5 @@ export declare class PayBridgeRouter {
|
|
|
39
47
|
verifyWebhook(body: any, headers: any, providerName: Provider): boolean;
|
|
40
48
|
private filterProviders;
|
|
41
49
|
private sleep;
|
|
50
|
+
private recordLedgerEntry;
|
|
42
51
|
}
|
package/dist/router.js
CHANGED
|
@@ -8,6 +8,8 @@ const routing_types_1 = require("./routing-types");
|
|
|
8
8
|
const strategies_1 = require("./strategies");
|
|
9
9
|
const circuit_breaker_1 = require("./circuit-breaker");
|
|
10
10
|
const fetch_1 = require("./utils/fetch");
|
|
11
|
+
const router_events_1 = require("./router-events");
|
|
12
|
+
const tracer_1 = require("./tracer");
|
|
11
13
|
function sanitizeErrorMessage(msg) {
|
|
12
14
|
if (!msg)
|
|
13
15
|
return 'unknown error';
|
|
@@ -27,6 +29,7 @@ class WebhookDuplicateError extends Error {
|
|
|
27
29
|
exports.WebhookDuplicateError = WebhookDuplicateError;
|
|
28
30
|
class PayBridgeRouter {
|
|
29
31
|
constructor(config) {
|
|
32
|
+
this.events = new router_events_1.RouterEventEmitter();
|
|
30
33
|
this.rrIndex = 0;
|
|
31
34
|
this.config = config;
|
|
32
35
|
this.providers = config.providers.map(p => ({
|
|
@@ -41,12 +44,36 @@ class PayBridgeRouter {
|
|
|
41
44
|
retryDelayMs: config.fallback?.retryDelayMs ?? 250,
|
|
42
45
|
};
|
|
43
46
|
this.idempotencyStore = config.idempotencyStore;
|
|
47
|
+
this.ledger = config.ledger;
|
|
48
|
+
this.tracer = config.tracer ?? tracer_1.noopTracer;
|
|
44
49
|
this.circuitBreakers = new Map();
|
|
45
50
|
for (const p of this.providers) {
|
|
46
51
|
const name = p.instance.getProviderName();
|
|
47
|
-
|
|
52
|
+
const breaker = new circuit_breaker_1.CircuitBreaker(name, {
|
|
48
53
|
store: config.circuitBreakerStore,
|
|
49
|
-
})
|
|
54
|
+
});
|
|
55
|
+
breaker.events.on('opened', (key) => {
|
|
56
|
+
this.events.emitEvent({
|
|
57
|
+
type: 'circuit.opened',
|
|
58
|
+
provider: key,
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
breaker.events.on('half_opened', (key) => {
|
|
63
|
+
this.events.emitEvent({
|
|
64
|
+
type: 'circuit.half_opened',
|
|
65
|
+
provider: key,
|
|
66
|
+
timestamp: new Date().toISOString(),
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
breaker.events.on('closed', (key) => {
|
|
70
|
+
this.events.emitEvent({
|
|
71
|
+
type: 'circuit.closed',
|
|
72
|
+
provider: key,
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
this.circuitBreakers.set(name, breaker);
|
|
50
77
|
}
|
|
51
78
|
}
|
|
52
79
|
async createPayment(params) {
|
|
@@ -82,9 +109,24 @@ class PayBridgeRouter {
|
|
|
82
109
|
continue;
|
|
83
110
|
}
|
|
84
111
|
const startTime = Date.now();
|
|
112
|
+
const span = this.tracer.startSpan('paybridge.router.createPayment', {
|
|
113
|
+
'paybridge.provider': providerName,
|
|
114
|
+
'paybridge.strategy': this.strategy,
|
|
115
|
+
'paybridge.attempt': attempts.length + 1,
|
|
116
|
+
});
|
|
117
|
+
this.events.emitEvent({
|
|
118
|
+
type: 'attempt.start',
|
|
119
|
+
provider: providerName,
|
|
120
|
+
operation: 'createPayment',
|
|
121
|
+
reference: params.reference,
|
|
122
|
+
attempt: attempts.length + 1,
|
|
123
|
+
timestamp: new Date().toISOString(),
|
|
124
|
+
});
|
|
85
125
|
try {
|
|
86
126
|
const result = await providerMeta.instance.createPayment(params);
|
|
87
127
|
const latencyMs = Date.now() - startTime;
|
|
128
|
+
span.setAttribute('paybridge.payment.id', result.id);
|
|
129
|
+
span.setAttribute('paybridge.payment.status', result.status);
|
|
88
130
|
if (breaker)
|
|
89
131
|
await breaker.recordSuccess();
|
|
90
132
|
attempts.push({
|
|
@@ -92,21 +134,59 @@ class PayBridgeRouter {
|
|
|
92
134
|
status: 'success',
|
|
93
135
|
latencyMs,
|
|
94
136
|
});
|
|
137
|
+
this.events.emitEvent({
|
|
138
|
+
type: 'attempt.success',
|
|
139
|
+
provider: providerName,
|
|
140
|
+
operation: 'createPayment',
|
|
141
|
+
reference: params.reference,
|
|
142
|
+
durationMs: latencyMs,
|
|
143
|
+
attempt: attempts.length,
|
|
144
|
+
timestamp: new Date().toISOString(),
|
|
145
|
+
});
|
|
146
|
+
this.events.emitEvent({
|
|
147
|
+
type: 'request.success',
|
|
148
|
+
provider: providerName,
|
|
149
|
+
operation: 'createPayment',
|
|
150
|
+
reference: params.reference,
|
|
151
|
+
durationMs: latencyMs,
|
|
152
|
+
timestamp: new Date().toISOString(),
|
|
153
|
+
});
|
|
154
|
+
await this.recordLedgerEntry({
|
|
155
|
+
id: `${providerName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
operation: 'createPayment',
|
|
158
|
+
provider: providerName,
|
|
159
|
+
reference: params.reference,
|
|
160
|
+
providerId: result.id,
|
|
161
|
+
status: 'success',
|
|
162
|
+
amount: params.amount,
|
|
163
|
+
currency: params.currency,
|
|
164
|
+
durationMs: latencyMs,
|
|
165
|
+
});
|
|
95
166
|
const routingMeta = {
|
|
96
167
|
attempts,
|
|
97
168
|
chosenProvider: providerName,
|
|
98
169
|
strategy: this.strategy,
|
|
99
170
|
};
|
|
171
|
+
span.end();
|
|
100
172
|
return {
|
|
101
173
|
...result,
|
|
102
174
|
routingMeta,
|
|
103
175
|
};
|
|
104
176
|
}
|
|
105
177
|
catch (error) {
|
|
178
|
+
span.recordException?.(error instanceof Error ? error : new Error(String(error)));
|
|
106
179
|
const latencyMs = Date.now() - startTime;
|
|
107
180
|
lastError = error;
|
|
108
181
|
const isRateLimited = error instanceof fetch_1.HttpError &&
|
|
109
182
|
(error.status === 429 || (error.status === 503 && error.retryAfterMs !== undefined));
|
|
183
|
+
let errorCode = error.code;
|
|
184
|
+
if (error instanceof fetch_1.FetchTimeoutError) {
|
|
185
|
+
errorCode = 'TIMEOUT';
|
|
186
|
+
}
|
|
187
|
+
else if (isRateLimited) {
|
|
188
|
+
errorCode = 'RATE_LIMITED';
|
|
189
|
+
}
|
|
110
190
|
if (isRateLimited) {
|
|
111
191
|
attempts.push({
|
|
112
192
|
provider: providerName,
|
|
@@ -115,14 +195,34 @@ class PayBridgeRouter {
|
|
|
115
195
|
errorMessage: sanitizeErrorMessage(error.message),
|
|
116
196
|
latencyMs,
|
|
117
197
|
});
|
|
198
|
+
this.events.emitEvent({
|
|
199
|
+
type: 'attempt.rate_limited',
|
|
200
|
+
provider: providerName,
|
|
201
|
+
operation: 'createPayment',
|
|
202
|
+
reference: params.reference,
|
|
203
|
+
durationMs: latencyMs,
|
|
204
|
+
errorCode: 'RATE_LIMITED',
|
|
205
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
206
|
+
attempt: attempts.length,
|
|
207
|
+
timestamp: new Date().toISOString(),
|
|
208
|
+
});
|
|
209
|
+
await this.recordLedgerEntry({
|
|
210
|
+
id: `${providerName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
211
|
+
timestamp: new Date().toISOString(),
|
|
212
|
+
operation: 'createPayment',
|
|
213
|
+
provider: providerName,
|
|
214
|
+
reference: params.reference,
|
|
215
|
+
status: 'rate_limited',
|
|
216
|
+
amount: params.amount,
|
|
217
|
+
currency: params.currency,
|
|
218
|
+
durationMs: latencyMs,
|
|
219
|
+
errorCode: 'RATE_LIMITED',
|
|
220
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
221
|
+
});
|
|
118
222
|
}
|
|
119
223
|
else {
|
|
120
224
|
if (breaker)
|
|
121
225
|
await breaker.recordFailure();
|
|
122
|
-
let errorCode = error.code;
|
|
123
|
-
if (error instanceof fetch_1.FetchTimeoutError) {
|
|
124
|
-
errorCode = 'TIMEOUT';
|
|
125
|
-
}
|
|
126
226
|
attempts.push({
|
|
127
227
|
provider: providerName,
|
|
128
228
|
status: 'failed',
|
|
@@ -130,13 +230,48 @@ class PayBridgeRouter {
|
|
|
130
230
|
errorMessage: sanitizeErrorMessage(error.message),
|
|
131
231
|
latencyMs,
|
|
132
232
|
});
|
|
233
|
+
const eventType = error instanceof fetch_1.FetchTimeoutError ? 'attempt.timeout' : 'attempt.failure';
|
|
234
|
+
this.events.emitEvent({
|
|
235
|
+
type: eventType,
|
|
236
|
+
provider: providerName,
|
|
237
|
+
operation: 'createPayment',
|
|
238
|
+
reference: params.reference,
|
|
239
|
+
durationMs: latencyMs,
|
|
240
|
+
errorCode,
|
|
241
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
242
|
+
attempt: attempts.length,
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
});
|
|
245
|
+
const ledgerStatus = error instanceof fetch_1.FetchTimeoutError ? 'timeout' : 'failed';
|
|
246
|
+
await this.recordLedgerEntry({
|
|
247
|
+
id: `${providerName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
248
|
+
timestamp: new Date().toISOString(),
|
|
249
|
+
operation: 'createPayment',
|
|
250
|
+
provider: providerName,
|
|
251
|
+
reference: params.reference,
|
|
252
|
+
status: ledgerStatus,
|
|
253
|
+
amount: params.amount,
|
|
254
|
+
currency: params.currency,
|
|
255
|
+
durationMs: latencyMs,
|
|
256
|
+
errorCode,
|
|
257
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
258
|
+
});
|
|
133
259
|
}
|
|
260
|
+
span.setAttribute('paybridge.error.code', errorCode ?? 'unknown');
|
|
261
|
+
span.end();
|
|
134
262
|
if (!this.fallback.enabled || attempts.length >= (this.fallback.maxAttempts ?? 3)) {
|
|
135
263
|
break;
|
|
136
264
|
}
|
|
137
265
|
await this.sleep(this.fallback.retryDelayMs ?? 250);
|
|
138
266
|
}
|
|
139
267
|
}
|
|
268
|
+
this.events.emitEvent({
|
|
269
|
+
type: 'request.failure',
|
|
270
|
+
operation: 'createPayment',
|
|
271
|
+
reference: params.reference,
|
|
272
|
+
errorMessage: lastError?.message || 'All providers failed',
|
|
273
|
+
timestamp: new Date().toISOString(),
|
|
274
|
+
});
|
|
140
275
|
throw new routing_types_1.RoutingError(`All providers failed: ${lastError?.message || 'Unknown error'}`, attempts);
|
|
141
276
|
}
|
|
142
277
|
async createSubscription(params) {
|
|
@@ -169,6 +304,14 @@ class PayBridgeRouter {
|
|
|
169
304
|
continue;
|
|
170
305
|
}
|
|
171
306
|
const startTime = Date.now();
|
|
307
|
+
this.events.emitEvent({
|
|
308
|
+
type: 'attempt.start',
|
|
309
|
+
provider: providerName,
|
|
310
|
+
operation: 'createSubscription',
|
|
311
|
+
reference: params.reference,
|
|
312
|
+
attempt: attempts.length + 1,
|
|
313
|
+
timestamp: new Date().toISOString(),
|
|
314
|
+
});
|
|
172
315
|
try {
|
|
173
316
|
const result = await providerMeta.instance.createSubscription(params);
|
|
174
317
|
const latencyMs = Date.now() - startTime;
|
|
@@ -179,6 +322,35 @@ class PayBridgeRouter {
|
|
|
179
322
|
status: 'success',
|
|
180
323
|
latencyMs,
|
|
181
324
|
});
|
|
325
|
+
this.events.emitEvent({
|
|
326
|
+
type: 'attempt.success',
|
|
327
|
+
provider: providerName,
|
|
328
|
+
operation: 'createSubscription',
|
|
329
|
+
reference: params.reference,
|
|
330
|
+
durationMs: latencyMs,
|
|
331
|
+
attempt: attempts.length,
|
|
332
|
+
timestamp: new Date().toISOString(),
|
|
333
|
+
});
|
|
334
|
+
this.events.emitEvent({
|
|
335
|
+
type: 'request.success',
|
|
336
|
+
provider: providerName,
|
|
337
|
+
operation: 'createSubscription',
|
|
338
|
+
reference: params.reference,
|
|
339
|
+
durationMs: latencyMs,
|
|
340
|
+
timestamp: new Date().toISOString(),
|
|
341
|
+
});
|
|
342
|
+
await this.recordLedgerEntry({
|
|
343
|
+
id: `${providerName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
344
|
+
timestamp: new Date().toISOString(),
|
|
345
|
+
operation: 'createSubscription',
|
|
346
|
+
provider: providerName,
|
|
347
|
+
reference: params.reference,
|
|
348
|
+
providerId: result.id,
|
|
349
|
+
status: 'success',
|
|
350
|
+
amount: params.amount,
|
|
351
|
+
currency: params.currency,
|
|
352
|
+
durationMs: latencyMs,
|
|
353
|
+
});
|
|
182
354
|
return result;
|
|
183
355
|
}
|
|
184
356
|
catch (error) {
|
|
@@ -194,6 +366,30 @@ class PayBridgeRouter {
|
|
|
194
366
|
errorMessage: sanitizeErrorMessage(error.message),
|
|
195
367
|
latencyMs,
|
|
196
368
|
});
|
|
369
|
+
this.events.emitEvent({
|
|
370
|
+
type: 'attempt.rate_limited',
|
|
371
|
+
provider: providerName,
|
|
372
|
+
operation: 'createSubscription',
|
|
373
|
+
reference: params.reference,
|
|
374
|
+
durationMs: latencyMs,
|
|
375
|
+
errorCode: 'RATE_LIMITED',
|
|
376
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
377
|
+
attempt: attempts.length,
|
|
378
|
+
timestamp: new Date().toISOString(),
|
|
379
|
+
});
|
|
380
|
+
await this.recordLedgerEntry({
|
|
381
|
+
id: `${providerName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
382
|
+
timestamp: new Date().toISOString(),
|
|
383
|
+
operation: 'createSubscription',
|
|
384
|
+
provider: providerName,
|
|
385
|
+
reference: params.reference,
|
|
386
|
+
status: 'rate_limited',
|
|
387
|
+
amount: params.amount,
|
|
388
|
+
currency: params.currency,
|
|
389
|
+
durationMs: latencyMs,
|
|
390
|
+
errorCode: 'RATE_LIMITED',
|
|
391
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
392
|
+
});
|
|
197
393
|
}
|
|
198
394
|
else {
|
|
199
395
|
if (breaker)
|
|
@@ -209,6 +405,32 @@ class PayBridgeRouter {
|
|
|
209
405
|
errorMessage: sanitizeErrorMessage(error.message),
|
|
210
406
|
latencyMs,
|
|
211
407
|
});
|
|
408
|
+
const eventType = error instanceof fetch_1.FetchTimeoutError ? 'attempt.timeout' : 'attempt.failure';
|
|
409
|
+
this.events.emitEvent({
|
|
410
|
+
type: eventType,
|
|
411
|
+
provider: providerName,
|
|
412
|
+
operation: 'createSubscription',
|
|
413
|
+
reference: params.reference,
|
|
414
|
+
durationMs: latencyMs,
|
|
415
|
+
errorCode,
|
|
416
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
417
|
+
attempt: attempts.length,
|
|
418
|
+
timestamp: new Date().toISOString(),
|
|
419
|
+
});
|
|
420
|
+
const ledgerStatus = error instanceof fetch_1.FetchTimeoutError ? 'timeout' : 'failed';
|
|
421
|
+
await this.recordLedgerEntry({
|
|
422
|
+
id: `${providerName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
423
|
+
timestamp: new Date().toISOString(),
|
|
424
|
+
operation: 'createSubscription',
|
|
425
|
+
provider: providerName,
|
|
426
|
+
reference: params.reference,
|
|
427
|
+
status: ledgerStatus,
|
|
428
|
+
amount: params.amount,
|
|
429
|
+
currency: params.currency,
|
|
430
|
+
durationMs: latencyMs,
|
|
431
|
+
errorCode,
|
|
432
|
+
errorMessage: sanitizeErrorMessage(error.message),
|
|
433
|
+
});
|
|
212
434
|
}
|
|
213
435
|
if (!this.fallback.enabled || attempts.length >= (this.fallback.maxAttempts ?? 3)) {
|
|
214
436
|
break;
|
|
@@ -216,6 +438,13 @@ class PayBridgeRouter {
|
|
|
216
438
|
await this.sleep(this.fallback.retryDelayMs ?? 250);
|
|
217
439
|
}
|
|
218
440
|
}
|
|
441
|
+
this.events.emitEvent({
|
|
442
|
+
type: 'request.failure',
|
|
443
|
+
operation: 'createSubscription',
|
|
444
|
+
reference: params.reference,
|
|
445
|
+
errorMessage: lastError?.message || 'All providers failed',
|
|
446
|
+
timestamp: new Date().toISOString(),
|
|
447
|
+
});
|
|
219
448
|
throw new routing_types_1.RoutingError(`All providers failed for subscription: ${lastError?.message || 'Unknown error'}`, attempts);
|
|
220
449
|
}
|
|
221
450
|
async getPayment(id, provider) {
|
|
@@ -269,6 +498,13 @@ class PayBridgeRouter {
|
|
|
269
498
|
const ttlMs = 24 * 60 * 60 * 1000;
|
|
270
499
|
const isNew = await this.idempotencyStore.recordIfNew(key, ttlMs);
|
|
271
500
|
if (!isNew) {
|
|
501
|
+
this.events.emitEvent({
|
|
502
|
+
type: 'webhook.duplicate',
|
|
503
|
+
provider: providerName,
|
|
504
|
+
operation: 'parseWebhook',
|
|
505
|
+
reference: eventId,
|
|
506
|
+
timestamp: new Date().toISOString(),
|
|
507
|
+
});
|
|
272
508
|
throw new WebhookDuplicateError(providerName, eventId);
|
|
273
509
|
}
|
|
274
510
|
}
|
|
@@ -300,5 +536,21 @@ class PayBridgeRouter {
|
|
|
300
536
|
sleep(ms) {
|
|
301
537
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
302
538
|
}
|
|
539
|
+
async recordLedgerEntry(entry) {
|
|
540
|
+
if (!this.ledger)
|
|
541
|
+
return;
|
|
542
|
+
try {
|
|
543
|
+
await this.ledger.append(entry);
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
this.events.emitEvent({
|
|
547
|
+
type: 'attempt.failure',
|
|
548
|
+
operation: entry.operation,
|
|
549
|
+
provider: entry.provider,
|
|
550
|
+
errorMessage: 'Ledger write failed',
|
|
551
|
+
timestamp: new Date().toISOString(),
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
303
555
|
}
|
|
304
556
|
exports.PayBridgeRouter = PayBridgeRouter;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RedisLike } from './redis';
|
|
2
|
+
import type { LedgerStore } from '../ledger';
|
|
3
|
+
export interface RedisLedgerStoreOptions {
|
|
4
|
+
redis: RedisLike;
|
|
5
|
+
keyPrefix?: string;
|
|
6
|
+
maxEntries?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function createRedisLedgerStore(opts: RedisLedgerStoreOptions): LedgerStore;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRedisLedgerStore = createRedisLedgerStore;
|
|
4
|
+
function createRedisLedgerStore(opts) {
|
|
5
|
+
const prefix = opts.keyPrefix ?? 'pb:ledger:';
|
|
6
|
+
const maxEntries = opts.maxEntries ?? 100000;
|
|
7
|
+
const listKey = `${prefix}list`;
|
|
8
|
+
const refIndexPrefix = `${prefix}ref:`;
|
|
9
|
+
if (!opts.redis.lpush || !opts.redis.ltrim || !opts.redis.lrange || !opts.redis.sadd || !opts.redis.smembers || !opts.redis.expire) {
|
|
10
|
+
throw new Error('Redis client does not support required list/set operations for ledger store');
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
async append(entry) {
|
|
14
|
+
const serialized = JSON.stringify(entry);
|
|
15
|
+
await opts.redis.lpush(listKey, serialized);
|
|
16
|
+
await opts.redis.ltrim(listKey, 0, maxEntries - 1);
|
|
17
|
+
if (entry.reference) {
|
|
18
|
+
const refKey = `${refIndexPrefix}${entry.reference}`;
|
|
19
|
+
await opts.redis.sadd(refKey, entry.id);
|
|
20
|
+
await opts.redis.expire(refKey, 86400 * 30);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
async query(filter) {
|
|
24
|
+
let entries = [];
|
|
25
|
+
if (filter.reference) {
|
|
26
|
+
const refKey = `${refIndexPrefix}${filter.reference}`;
|
|
27
|
+
const ids = await opts.redis.smembers(refKey);
|
|
28
|
+
if (!ids || ids.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
const allEntries = await opts.redis.lrange(listKey, 0, -1);
|
|
31
|
+
entries = allEntries
|
|
32
|
+
.map((s) => JSON.parse(s))
|
|
33
|
+
.filter((e) => ids.includes(e.id));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const allEntries = await opts.redis.lrange(listKey, 0, -1);
|
|
37
|
+
entries = allEntries.map((s) => JSON.parse(s));
|
|
38
|
+
}
|
|
39
|
+
if (filter.provider)
|
|
40
|
+
entries = entries.filter(e => e.provider === filter.provider);
|
|
41
|
+
if (filter.status)
|
|
42
|
+
entries = entries.filter(e => e.status === filter.status);
|
|
43
|
+
if (filter.fromTimestamp)
|
|
44
|
+
entries = entries.filter(e => e.timestamp >= filter.fromTimestamp);
|
|
45
|
+
if (filter.toTimestamp)
|
|
46
|
+
entries = entries.filter(e => e.timestamp <= filter.toTimestamp);
|
|
47
|
+
if (filter.limit)
|
|
48
|
+
entries = entries.slice(0, filter.limit);
|
|
49
|
+
return entries;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/stores/redis.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export interface RedisLike {
|
|
|
10
10
|
get(key: string): Promise<string | null>;
|
|
11
11
|
set(key: string, value: string, ...args: any[]): Promise<any>;
|
|
12
12
|
del(key: string): Promise<any>;
|
|
13
|
+
lpush?(key: string, ...values: string[]): Promise<number>;
|
|
14
|
+
ltrim?(key: string, start: number, stop: number): Promise<string>;
|
|
15
|
+
lrange?(key: string, start: number, stop: number): Promise<string[]>;
|
|
16
|
+
sadd?(key: string, ...members: string[]): Promise<number>;
|
|
17
|
+
smembers?(key: string): Promise<string[]>;
|
|
18
|
+
expire?(key: string, seconds: number): Promise<number>;
|
|
13
19
|
}
|
|
14
20
|
export interface RedisStoreOptions {
|
|
15
21
|
prefix?: string;
|
package/dist/tracer.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SpanLike {
|
|
2
|
+
setAttribute(key: string, value: string | number | boolean): void;
|
|
3
|
+
recordException?(error: Error): void;
|
|
4
|
+
end(): void;
|
|
5
|
+
}
|
|
6
|
+
export interface TracerLike {
|
|
7
|
+
startSpan(name: string, attributes?: Record<string, string | number | boolean>): SpanLike;
|
|
8
|
+
}
|
|
9
|
+
export declare const noopTracer: TracerLike;
|
package/dist/tracer.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paybridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "One API for fiat + crypto payments. Multi-provider routing, automatic failover, MoonPay on/off-ramp. SA-first, global-ready.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|