paybridge 0.2.3 → 0.3.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.
- package/README.md +3 -1
- package/dist/crypto/index.d.ts +4 -1
- package/dist/crypto/index.js +6 -1
- package/dist/crypto/router.d.ts +2 -1
- package/dist/crypto/router.js +8 -0
- package/dist/crypto/types.d.ts +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -1
- package/dist/router.d.ts +10 -1
- package/dist/router.js +25 -3
- package/dist/stores/redis-idempotency.d.ts +10 -0
- package/dist/stores/redis-idempotency.js +15 -0
- package/dist/strategies.js +2 -2
- package/dist/webhook-idempotency-store.d.ts +16 -0
- package/dist/webhook-idempotency-store.js +39 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ Perfect for demos, learning, and rapid prototyping. See [playground/README.md](p
|
|
|
50
50
|
|
|
51
51
|
## Quick Start
|
|
52
52
|
|
|
53
|
+
> **Upgrading from 0.1 or 0.2?** See [docs/migration.md](docs/migration.md).
|
|
54
|
+
|
|
53
55
|
### One-time Payment
|
|
54
56
|
|
|
55
57
|
```typescript
|
|
@@ -205,7 +207,7 @@ app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
|
|
|
205
207
|
**Notes:**
|
|
206
208
|
- `⛔` marks features the underlying provider's API doesn't support — those methods throw a clear error explaining the limitation. Use a different provider for that capability or use `PayBridgeRouter` to route accordingly.
|
|
207
209
|
- **Yellow Card** is gated behind `@experimental` until partner API documentation is verified — it logs a warning on instantiation. Do not use in production without partner-confirmed spec.
|
|
208
|
-
- **Sandbox testing.** PayFast / PayStack / Stripe / Peach / Flutterwave are wired and unit-tested, but have not yet been validated against live sandbox credentials.
|
|
210
|
+
- **Sandbox testing.** PayFast / PayStack / Stripe / Peach / Flutterwave are wired and unit-tested, but have not yet been validated against live sandbox credentials. To validate against real sandboxes, set the relevant `*_API_KEY` env vars and run `npm run test:e2e:sandbox`.
|
|
209
211
|
|
|
210
212
|
## Provider Configuration
|
|
211
213
|
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CryptoRamp — Unified crypto on/off-ramp SDK
|
|
3
3
|
*/
|
|
4
|
+
import { CryptoRampProvider } from './base';
|
|
4
5
|
import { OnRampParams, OffRampParams, RampQuote, RampResult, CryptoRampCapabilities } from './types';
|
|
5
6
|
export * from './types';
|
|
6
7
|
export * from './base';
|
|
@@ -21,7 +22,9 @@ export interface CryptoRampConfig {
|
|
|
21
22
|
}
|
|
22
23
|
export declare class CryptoRamp {
|
|
23
24
|
private provider;
|
|
24
|
-
constructor(config: CryptoRampConfig
|
|
25
|
+
constructor(config: CryptoRampConfig | {
|
|
26
|
+
provider: CryptoRampProvider;
|
|
27
|
+
});
|
|
25
28
|
private createProvider;
|
|
26
29
|
getQuote(direction: 'on' | 'off', fiatAmount: number, fiatCurrency: string, cryptoAsset: string, network: string): Promise<RampQuote>;
|
|
27
30
|
createOnRamp(params: OnRampParams): Promise<RampResult>;
|
package/dist/crypto/index.js
CHANGED
|
@@ -29,7 +29,12 @@ __exportStar(require("./mock"), exports);
|
|
|
29
29
|
__exportStar(require("./router"), exports);
|
|
30
30
|
class CryptoRamp {
|
|
31
31
|
constructor(config) {
|
|
32
|
-
|
|
32
|
+
if ('provider' in config && typeof config.provider === 'object' && 'name' in config.provider) {
|
|
33
|
+
this.provider = config.provider;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
this.provider = this.createProvider(config);
|
|
37
|
+
}
|
|
33
38
|
}
|
|
34
39
|
createProvider(config) {
|
|
35
40
|
const { provider, credentials, sandbox = true, webhookSecret } = config;
|
package/dist/crypto/router.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface CryptoRampRouterConfig {
|
|
|
8
8
|
provider: CryptoRamp;
|
|
9
9
|
priority?: number;
|
|
10
10
|
}>;
|
|
11
|
-
strategy?: 'cheapest' | 'priority' | 'round-robin';
|
|
11
|
+
strategy?: 'cheapest' | 'priority' | 'round-robin' | 'fastest';
|
|
12
12
|
fallback?: {
|
|
13
13
|
enabled: boolean;
|
|
14
14
|
maxAttempts?: number;
|
|
@@ -16,6 +16,7 @@ export interface CryptoRampRouterConfig {
|
|
|
16
16
|
};
|
|
17
17
|
allowExperimental?: boolean;
|
|
18
18
|
circuitBreakerStore?: import('../circuit-breaker-store').CircuitBreakerStore;
|
|
19
|
+
idempotencyStore?: import('../webhook-idempotency-store').IdempotencyStore;
|
|
19
20
|
}
|
|
20
21
|
export declare class CryptoRampRouter {
|
|
21
22
|
private providers;
|
package/dist/crypto/router.js
CHANGED
|
@@ -265,6 +265,14 @@ class CryptoRampRouter {
|
|
|
265
265
|
const feeB = direction === 'on' ? capsB.fees.onRampPercent : capsB.fees.offRampPercent;
|
|
266
266
|
return feeA - feeB;
|
|
267
267
|
});
|
|
268
|
+
case 'fastest':
|
|
269
|
+
return [...providers].sort((a, b) => {
|
|
270
|
+
const capsA = a.instance.getCapabilities();
|
|
271
|
+
const capsB = b.instance.getCapabilities();
|
|
272
|
+
const latencyA = capsA.avgLatencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
273
|
+
const latencyB = capsB.avgLatencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
274
|
+
return latencyA - latencyB;
|
|
275
|
+
});
|
|
268
276
|
case 'priority':
|
|
269
277
|
return [...providers].sort((a, b) => {
|
|
270
278
|
const priorityA = a.priority ?? 0;
|
package/dist/crypto/types.d.ts
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,9 @@ export * from './circuit-breaker-store';
|
|
|
15
15
|
export * from './strategies';
|
|
16
16
|
export * from './router';
|
|
17
17
|
export * from './crypto';
|
|
18
|
+
export * from './webhook-idempotency-store';
|
|
18
19
|
export { createRedisCircuitBreakerStore, type RedisLike, type RedisStoreOptions } from './stores/redis';
|
|
20
|
+
export { createRedisIdempotencyStore, type RedisIdempotencyStoreOptions } from './stores/redis-idempotency';
|
|
19
21
|
export declare class PayBridge {
|
|
20
22
|
readonly provider: PaymentProvider;
|
|
21
23
|
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.createRedisCircuitBreakerStore = void 0;
|
|
23
|
+
exports.PayBridge = 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");
|
|
@@ -38,8 +38,11 @@ __exportStar(require("./circuit-breaker-store"), exports);
|
|
|
38
38
|
__exportStar(require("./strategies"), exports);
|
|
39
39
|
__exportStar(require("./router"), exports);
|
|
40
40
|
__exportStar(require("./crypto"), exports);
|
|
41
|
+
__exportStar(require("./webhook-idempotency-store"), exports);
|
|
41
42
|
var redis_1 = require("./stores/redis");
|
|
42
43
|
Object.defineProperty(exports, "createRedisCircuitBreakerStore", { enumerable: true, get: function () { return redis_1.createRedisCircuitBreakerStore; } });
|
|
44
|
+
var redis_idempotency_1 = require("./stores/redis-idempotency");
|
|
45
|
+
Object.defineProperty(exports, "createRedisIdempotencyStore", { enumerable: true, get: function () { return redis_idempotency_1.createRedisIdempotencyStore; } });
|
|
43
46
|
class PayBridge {
|
|
44
47
|
constructor(config) {
|
|
45
48
|
this.provider = this.createProvider(config);
|
package/dist/router.d.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
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
|
+
import type { IdempotencyStore } from './webhook-idempotency-store';
|
|
8
|
+
export declare class WebhookDuplicateError extends Error {
|
|
9
|
+
readonly name = "WebhookDuplicateError";
|
|
10
|
+
readonly eventId: string;
|
|
11
|
+
readonly provider: string;
|
|
12
|
+
constructor(provider: string, eventId: string);
|
|
13
|
+
}
|
|
7
14
|
export interface PayBridgeRouterConfig {
|
|
8
15
|
providers: Array<{
|
|
9
16
|
provider: PayBridge;
|
|
@@ -13,12 +20,14 @@ export interface PayBridgeRouterConfig {
|
|
|
13
20
|
strategy?: RoutingStrategy;
|
|
14
21
|
fallback?: FallbackConfig;
|
|
15
22
|
circuitBreakerStore?: import('./circuit-breaker-store').CircuitBreakerStore;
|
|
23
|
+
idempotencyStore?: IdempotencyStore;
|
|
16
24
|
}
|
|
17
25
|
export declare class PayBridgeRouter {
|
|
18
26
|
private providers;
|
|
19
27
|
private strategy;
|
|
20
28
|
private fallback;
|
|
21
29
|
private circuitBreakers;
|
|
30
|
+
private idempotencyStore?;
|
|
22
31
|
private rrIndex;
|
|
23
32
|
private config;
|
|
24
33
|
constructor(config: PayBridgeRouterConfig);
|
|
@@ -26,7 +35,7 @@ export declare class PayBridgeRouter {
|
|
|
26
35
|
createSubscription(params: CreateSubscriptionParams): Promise<SubscriptionResult>;
|
|
27
36
|
getPayment(id: string, provider?: string): Promise<PaymentResult>;
|
|
28
37
|
refund(params: RefundParams, provider?: string): Promise<RefundResult>;
|
|
29
|
-
parseWebhook(body: any, headers: any, providerName: Provider): WebhookEvent
|
|
38
|
+
parseWebhook(body: any, headers: any, providerName: Provider): Promise<WebhookEvent>;
|
|
30
39
|
verifyWebhook(body: any, headers: any, providerName: Provider): boolean;
|
|
31
40
|
private filterProviders;
|
|
32
41
|
private sleep;
|
package/dist/router.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* PayBridgeRouter — Multi-provider routing with fallback
|
|
4
4
|
*/
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.PayBridgeRouter = void 0;
|
|
6
|
+
exports.PayBridgeRouter = exports.WebhookDuplicateError = void 0;
|
|
7
7
|
const routing_types_1 = require("./routing-types");
|
|
8
8
|
const strategies_1 = require("./strategies");
|
|
9
9
|
const circuit_breaker_1 = require("./circuit-breaker");
|
|
@@ -16,6 +16,15 @@ function sanitizeErrorMessage(msg) {
|
|
|
16
16
|
.replace(/(api[_-]?key|secret|token|password)["':=\s]+\S+/gi, '$1=[REDACTED]')
|
|
17
17
|
.slice(0, 500);
|
|
18
18
|
}
|
|
19
|
+
class WebhookDuplicateError extends Error {
|
|
20
|
+
constructor(provider, eventId) {
|
|
21
|
+
super(`Webhook event already processed: ${provider}:${eventId}`);
|
|
22
|
+
this.name = 'WebhookDuplicateError';
|
|
23
|
+
this.eventId = eventId;
|
|
24
|
+
this.provider = provider;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.WebhookDuplicateError = WebhookDuplicateError;
|
|
19
28
|
class PayBridgeRouter {
|
|
20
29
|
constructor(config) {
|
|
21
30
|
this.rrIndex = 0;
|
|
@@ -31,6 +40,7 @@ class PayBridgeRouter {
|
|
|
31
40
|
maxAttempts: config.fallback?.maxAttempts ?? 3,
|
|
32
41
|
retryDelayMs: config.fallback?.retryDelayMs ?? 250,
|
|
33
42
|
};
|
|
43
|
+
this.idempotencyStore = config.idempotencyStore;
|
|
34
44
|
this.circuitBreakers = new Map();
|
|
35
45
|
for (const p of this.providers) {
|
|
36
46
|
const name = p.instance.getProviderName();
|
|
@@ -246,12 +256,24 @@ class PayBridgeRouter {
|
|
|
246
256
|
}
|
|
247
257
|
throw new Error(`Refund for payment ${params.paymentId} failed on all providers: ${lastError?.message || 'Unknown error'}`);
|
|
248
258
|
}
|
|
249
|
-
parseWebhook(body, headers, providerName) {
|
|
259
|
+
async parseWebhook(body, headers, providerName) {
|
|
250
260
|
const providerMeta = this.providers.find(p => p.instance.getProviderName() === providerName);
|
|
251
261
|
if (!providerMeta) {
|
|
252
262
|
throw new Error(`Unknown provider for webhook: ${providerName}`);
|
|
253
263
|
}
|
|
254
|
-
|
|
264
|
+
const event = providerMeta.instance.parseWebhook(body, headers);
|
|
265
|
+
if (this.idempotencyStore) {
|
|
266
|
+
const eventId = event.payment?.id || event.subscription?.id || event.refund?.id;
|
|
267
|
+
if (eventId) {
|
|
268
|
+
const key = `${providerName}:${eventId}`;
|
|
269
|
+
const ttlMs = 24 * 60 * 60 * 1000;
|
|
270
|
+
const isNew = await this.idempotencyStore.recordIfNew(key, ttlMs);
|
|
271
|
+
if (!isNew) {
|
|
272
|
+
throw new WebhookDuplicateError(providerName, eventId);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return event;
|
|
255
277
|
}
|
|
256
278
|
verifyWebhook(body, headers, providerName) {
|
|
257
279
|
const providerMeta = this.providers.find(p => p.instance.getProviderName() === providerName);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed idempotency store adapter
|
|
3
|
+
*/
|
|
4
|
+
import type { RedisLike } from './redis';
|
|
5
|
+
import type { IdempotencyStore } from '../webhook-idempotency-store';
|
|
6
|
+
export interface RedisIdempotencyStoreOptions {
|
|
7
|
+
redis: RedisLike;
|
|
8
|
+
keyPrefix?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function createRedisIdempotencyStore(opts: RedisIdempotencyStoreOptions): IdempotencyStore;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Redis-backed idempotency store adapter
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createRedisIdempotencyStore = createRedisIdempotencyStore;
|
|
7
|
+
function createRedisIdempotencyStore(opts) {
|
|
8
|
+
const prefix = opts.keyPrefix ?? 'pb:idem:';
|
|
9
|
+
return {
|
|
10
|
+
async recordIfNew(key, ttlMs) {
|
|
11
|
+
const result = await opts.redis.set(`${prefix}${key}`, '1', 'PX', ttlMs, 'NX');
|
|
12
|
+
return result === 'OK';
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
package/dist/strategies.js
CHANGED
|
@@ -19,8 +19,8 @@ exports.strategies = {
|
|
|
19
19
|
return [...providers].sort((a, b) => {
|
|
20
20
|
const capsA = a.instance.provider.getCapabilities();
|
|
21
21
|
const capsB = b.instance.provider.getCapabilities();
|
|
22
|
-
const latencyA = capsA.avgLatencyMs ??
|
|
23
|
-
const latencyB = capsB.avgLatencyMs ??
|
|
22
|
+
const latencyA = capsA.avgLatencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
23
|
+
const latencyB = capsB.avgLatencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
24
24
|
return latencyA - latencyB;
|
|
25
25
|
});
|
|
26
26
|
},
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook idempotency storage abstraction
|
|
3
|
+
*/
|
|
4
|
+
export interface IdempotencyStore {
|
|
5
|
+
recordIfNew(key: string, ttlMs: number): Promise<boolean>;
|
|
6
|
+
}
|
|
7
|
+
export declare class InMemoryIdempotencyStore implements IdempotencyStore {
|
|
8
|
+
private store;
|
|
9
|
+
private cleanupInterval?;
|
|
10
|
+
constructor(opts?: {
|
|
11
|
+
cleanupIntervalMs?: number;
|
|
12
|
+
});
|
|
13
|
+
recordIfNew(key: string, ttlMs: number): Promise<boolean>;
|
|
14
|
+
private cleanup;
|
|
15
|
+
destroy(): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Webhook idempotency storage abstraction
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.InMemoryIdempotencyStore = void 0;
|
|
7
|
+
class InMemoryIdempotencyStore {
|
|
8
|
+
constructor(opts = {}) {
|
|
9
|
+
this.store = new Map();
|
|
10
|
+
if (opts.cleanupIntervalMs) {
|
|
11
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), opts.cleanupIntervalMs);
|
|
12
|
+
this.cleanupInterval.unref?.();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async recordIfNew(key, ttlMs) {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
const existing = this.store.get(key);
|
|
18
|
+
if (existing !== undefined && existing > now) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
this.store.set(key, now + ttlMs);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
cleanup() {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const keysToDelete = [];
|
|
27
|
+
this.store.forEach((expiresAt, k) => {
|
|
28
|
+
if (expiresAt <= now)
|
|
29
|
+
keysToDelete.push(k);
|
|
30
|
+
});
|
|
31
|
+
keysToDelete.forEach(k => this.store.delete(k));
|
|
32
|
+
}
|
|
33
|
+
destroy() {
|
|
34
|
+
if (this.cleanupInterval)
|
|
35
|
+
clearInterval(this.cleanupInterval);
|
|
36
|
+
this.store.clear();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.InMemoryIdempotencyStore = InMemoryIdempotencyStore;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paybridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"prepublishOnly": "npm run clean && npm run build",
|
|
12
12
|
"test": "tsc && tsc --project tsconfig.test.json && node --test 'dist-test/**/*.test.js'",
|
|
13
13
|
"test:e2e:moonpay": "tsx tests/e2e/moonpay-sandbox.ts",
|
|
14
|
-
"test:e2e:yellowcard": "tsx tests/e2e/yellowcard-sandbox.ts"
|
|
14
|
+
"test:e2e:yellowcard": "tsx tests/e2e/yellowcard-sandbox.ts",
|
|
15
|
+
"test:e2e:sandbox": "tsx tests/e2e/sandbox-validate.ts"
|
|
15
16
|
},
|
|
16
17
|
"keywords": [
|
|
17
18
|
"payments",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@types/node": "^18.19.130",
|
|
53
|
+
"tsx": "^4.19.2",
|
|
52
54
|
"typescript": "^5.9.3"
|
|
53
55
|
}
|
|
54
56
|
}
|