paybridge 0.11.0 → 1.0.0-rc.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 CHANGED
@@ -141,6 +141,65 @@ Reconciled: 5
141
141
 
142
142
  Exit code 1 if any mismatch, 0 if clean. Add `--webhook-url` to POST mismatch reports to Slack/Discord/your ops channel. Use `--json` for pipeline integration.
143
143
 
144
+ ## Postgres Ledger
145
+
146
+ The ledger stores every payment attempt and outcome. In-memory and Redis adapters are ephemeral. For durable transaction history, use the Postgres adapter.
147
+
148
+ ### Schema Setup
149
+
150
+ Create the table once via your migration tool:
151
+
152
+ ```typescript
153
+ import { getPostgresLedgerTableSql } from 'paybridge';
154
+
155
+ const sql = getPostgresLedgerTableSql();
156
+ await pool.query(sql);
157
+ ```
158
+
159
+ Or run the SQL manually:
160
+
161
+ ```sql
162
+ CREATE TABLE paybridge_ledger (
163
+ id TEXT PRIMARY KEY,
164
+ timestamp TIMESTAMPTZ NOT NULL,
165
+ operation TEXT NOT NULL,
166
+ provider TEXT NOT NULL,
167
+ reference TEXT,
168
+ provider_id TEXT,
169
+ status TEXT NOT NULL,
170
+ amount NUMERIC,
171
+ currency TEXT,
172
+ duration_ms INTEGER,
173
+ error_code TEXT,
174
+ error_message TEXT,
175
+ metadata JSONB
176
+ );
177
+
178
+ CREATE INDEX idx_paybridge_ledger_provider_timestamp
179
+ ON paybridge_ledger (provider, timestamp DESC);
180
+ CREATE INDEX idx_paybridge_ledger_reference
181
+ ON paybridge_ledger (reference) WHERE reference IS NOT NULL;
182
+ CREATE INDEX idx_paybridge_ledger_status
183
+ ON paybridge_ledger (status);
184
+ ```
185
+
186
+ ### Usage
187
+
188
+ ```typescript
189
+ import { Pool } from 'pg';
190
+ import { PayBridgeRouter, createPostgresLedgerStore } from 'paybridge';
191
+
192
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
193
+ const ledger = createPostgresLedgerStore({ pool });
194
+
195
+ const router = new PayBridgeRouter({
196
+ providers: [...],
197
+ ledger,
198
+ });
199
+ ```
200
+
201
+ Compatible with `pg` (`Pool`) and `postgres` (porsager) adapter wrappers. No runtime dep on `pg` — bring your own client.
202
+
144
203
  ## Quick Start
145
204
 
146
205
  > **Upgrading from 0.1 or 0.2?** See [docs/migration.md](docs/migration.md).
@@ -612,6 +671,34 @@ const router = new PayBridgeRouter({
612
671
 
613
672
  The Redis adapter works with both `ioredis` and `redis` (node-redis v4+) clients. State is eventually consistent across instances — race conditions during state transitions may cause a few extra failures, but correctness is preserved.
614
673
 
674
+ ### Intelligent routing — successRate strategy
675
+
676
+ Static fee tables lie. PayBridge routes by actual outcomes when you give it ledger access:
677
+
678
+ ```typescript
679
+ import { PayBridgeRouter, createSuccessRateStrategy, createPostgresLedgerStore } from 'paybridge';
680
+ import { Pool } from 'pg';
681
+
682
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
683
+ const ledger = createPostgresLedgerStore({ pool });
684
+
685
+ const strategy = createSuccessRateStrategy({
686
+ ledger,
687
+ windowMs: 24 * 60 * 60 * 1000,
688
+ fallback: 'cheapest',
689
+ });
690
+
691
+ const router = new PayBridgeRouter({
692
+ providers: [...],
693
+ strategy,
694
+ ledger,
695
+ });
696
+ ```
697
+
698
+ Providers are ranked by their real success rate from your own traffic. Below the minimum sample size, the configured fallback (default `cheapest`) takes over.
699
+
700
+ **Why this matters**: A provider with 1.4% fee but 92% success rate costs more per successful transaction than a 2.5% / 99.5% provider. `successRate` makes routing decisions based on real outcomes from your traffic, not static fee tables.
701
+
615
702
  ## Currency Handling
616
703
 
617
704
  PayBridge **always uses major currency units** (rands, dollars) in the API:
package/dist/index.d.ts CHANGED
@@ -22,6 +22,8 @@ export * from './tracer';
22
22
  export { createRedisCircuitBreakerStore, type RedisLike, type RedisStoreOptions } from './stores/redis';
23
23
  export { createRedisIdempotencyStore, type RedisIdempotencyStoreOptions } from './stores/redis-idempotency';
24
24
  export { createRedisLedgerStore, type RedisLedgerStoreOptions } from './stores/redis-ledger';
25
+ export { createPostgresLedgerStore, type PostgresLedgerStoreOptions, getCreateTableSql as getPostgresLedgerTableSql } from './stores/postgres-ledger';
26
+ export type { PgPoolLike, PgQueryResult } from './stores/postgres';
25
27
  export { runReconcile, type ReconcileDeps } from './cli/reconcile';
26
28
  export * from './cli/reconcile-types';
27
29
  export declare class PayBridge {
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.runReconcile = exports.createRedisLedgerStore = exports.createRedisIdempotencyStore = exports.createRedisCircuitBreakerStore = void 0;
23
+ exports.PayBridge = exports.runReconcile = exports.getPostgresLedgerTableSql = exports.createPostgresLedgerStore = 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");
@@ -54,6 +54,9 @@ var redis_idempotency_1 = require("./stores/redis-idempotency");
54
54
  Object.defineProperty(exports, "createRedisIdempotencyStore", { enumerable: true, get: function () { return redis_idempotency_1.createRedisIdempotencyStore; } });
55
55
  var redis_ledger_1 = require("./stores/redis-ledger");
56
56
  Object.defineProperty(exports, "createRedisLedgerStore", { enumerable: true, get: function () { return redis_ledger_1.createRedisLedgerStore; } });
57
+ var postgres_ledger_1 = require("./stores/postgres-ledger");
58
+ Object.defineProperty(exports, "createPostgresLedgerStore", { enumerable: true, get: function () { return postgres_ledger_1.createPostgresLedgerStore; } });
59
+ Object.defineProperty(exports, "getPostgresLedgerTableSql", { enumerable: true, get: function () { return postgres_ledger_1.getCreateTableSql; } });
57
60
  var reconcile_1 = require("./cli/reconcile");
58
61
  Object.defineProperty(exports, "runReconcile", { enumerable: true, get: function () { return reconcile_1.runReconcile; } });
59
62
  __exportStar(require("./cli/reconcile-types"), exports);
package/dist/router.d.ts CHANGED
@@ -4,6 +4,7 @@
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 SuccessRateStrategy } from './strategies';
7
8
  import type { IdempotencyStore } from './webhook-idempotency-store';
8
9
  import { RouterEventEmitter } from './router-events';
9
10
  import type { LedgerStore } from './ledger';
@@ -20,7 +21,7 @@ export interface PayBridgeRouterConfig {
20
21
  weight?: number;
21
22
  priority?: number;
22
23
  }>;
23
- strategy?: RoutingStrategy;
24
+ strategy?: RoutingStrategy | SuccessRateStrategy;
24
25
  fallback?: FallbackConfig;
25
26
  circuitBreakerStore?: import('./circuit-breaker-store').CircuitBreakerStore;
26
27
  idempotencyStore?: IdempotencyStore;
package/dist/router.js CHANGED
@@ -88,12 +88,18 @@ class PayBridgeRouter {
88
88
  if (filtered.length === 0) {
89
89
  throw new Error(`No providers support currency ${params.currency} with amount ${params.amount}`);
90
90
  }
91
- const strategyFn = (0, strategies_1.getStrategy)(this.strategy);
92
- const ordered = strategyFn(filtered, context, () => {
93
- const idx = this.rrIndex;
94
- this.rrIndex = (this.rrIndex + 1) % filtered.length;
95
- return idx;
96
- });
91
+ let ordered;
92
+ if (typeof this.strategy === 'string') {
93
+ const strategyFn = (0, strategies_1.getStrategy)(this.strategy);
94
+ ordered = strategyFn(filtered, context, () => {
95
+ const idx = this.rrIndex;
96
+ this.rrIndex = (this.rrIndex + 1) % filtered.length;
97
+ return idx;
98
+ });
99
+ }
100
+ else {
101
+ ordered = await this.strategy.order(filtered);
102
+ }
97
103
  const attempts = [];
98
104
  let lastError = null;
99
105
  for (const providerMeta of ordered) {
@@ -111,7 +117,7 @@ class PayBridgeRouter {
111
117
  const startTime = Date.now();
112
118
  const span = this.tracer.startSpan('paybridge.router.createPayment', {
113
119
  'paybridge.provider': providerName,
114
- 'paybridge.strategy': this.strategy,
120
+ 'paybridge.strategy': typeof this.strategy === 'string' ? this.strategy : 'successRate',
115
121
  'paybridge.attempt': attempts.length + 1,
116
122
  });
117
123
  this.events.emitEvent({
@@ -166,7 +172,7 @@ class PayBridgeRouter {
166
172
  const routingMeta = {
167
173
  attempts,
168
174
  chosenProvider: providerName,
169
- strategy: this.strategy,
175
+ strategy: typeof this.strategy === 'string' ? this.strategy : 'successRate',
170
176
  };
171
177
  span.end();
172
178
  return {
@@ -283,12 +289,18 @@ class PayBridgeRouter {
283
289
  if (filtered.length === 0) {
284
290
  throw new Error(`No providers support currency ${params.currency} with amount ${params.amount}`);
285
291
  }
286
- const strategyFn = (0, strategies_1.getStrategy)(this.strategy);
287
- const ordered = strategyFn(filtered, context, () => {
288
- const idx = this.rrIndex;
289
- this.rrIndex = (this.rrIndex + 1) % filtered.length;
290
- return idx;
291
- });
292
+ let ordered;
293
+ if (typeof this.strategy === 'string') {
294
+ const strategyFn = (0, strategies_1.getStrategy)(this.strategy);
295
+ ordered = strategyFn(filtered, context, () => {
296
+ const idx = this.rrIndex;
297
+ this.rrIndex = (this.rrIndex + 1) % filtered.length;
298
+ return idx;
299
+ });
300
+ }
301
+ else {
302
+ ordered = await this.strategy.order(filtered);
303
+ }
292
304
  const attempts = [];
293
305
  let lastError = null;
294
306
  for (const providerMeta of ordered) {
@@ -25,7 +25,7 @@ export interface RoutingAttempt {
25
25
  export interface RoutingMeta {
26
26
  attempts: RoutingAttempt[];
27
27
  chosenProvider: string;
28
- strategy: RoutingStrategy;
28
+ strategy: RoutingStrategy | 'successRate';
29
29
  }
30
30
  export type RoutingStrategy = 'cheapest' | 'fastest' | 'priority' | 'round-robin';
31
31
  export interface FallbackConfig {
@@ -0,0 +1,9 @@
1
+ import type { LedgerStore } from '../ledger';
2
+ import type { PgPoolLike } from './postgres';
3
+ export interface PostgresLedgerStoreOptions {
4
+ pool: PgPoolLike;
5
+ tableName?: string;
6
+ schema?: string;
7
+ }
8
+ export declare function getCreateTableSql(tableName?: string, schema?: string): string;
9
+ export declare function createPostgresLedgerStore(opts: PostgresLedgerStoreOptions): LedgerStore;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCreateTableSql = getCreateTableSql;
4
+ exports.createPostgresLedgerStore = createPostgresLedgerStore;
5
+ function getCreateTableSql(tableName = 'paybridge_ledger', schema = 'public') {
6
+ const fullTableName = `${schema}.${tableName}`;
7
+ return `CREATE TABLE ${fullTableName} (
8
+ id TEXT PRIMARY KEY,
9
+ timestamp TIMESTAMPTZ NOT NULL,
10
+ operation TEXT NOT NULL,
11
+ provider TEXT NOT NULL,
12
+ reference TEXT,
13
+ provider_id TEXT,
14
+ status TEXT NOT NULL,
15
+ amount NUMERIC,
16
+ currency TEXT,
17
+ duration_ms INTEGER,
18
+ error_code TEXT,
19
+ error_message TEXT,
20
+ metadata JSONB
21
+ );
22
+
23
+ CREATE INDEX idx_${tableName}_provider_timestamp
24
+ ON ${fullTableName} (provider, timestamp DESC);
25
+
26
+ CREATE INDEX idx_${tableName}_reference
27
+ ON ${fullTableName} (reference) WHERE reference IS NOT NULL;
28
+
29
+ CREATE INDEX idx_${tableName}_status
30
+ ON ${fullTableName} (status);`;
31
+ }
32
+ function createPostgresLedgerStore(opts) {
33
+ const tableName = opts.tableName ?? 'paybridge_ledger';
34
+ const schema = opts.schema ?? 'public';
35
+ const fullTableName = `${schema}.${tableName}`;
36
+ return {
37
+ async append(entry) {
38
+ const sql = `INSERT INTO ${fullTableName} (
39
+ id, timestamp, operation, provider, reference, provider_id,
40
+ status, amount, currency, duration_ms, error_code, error_message, metadata
41
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`;
42
+ const params = [
43
+ entry.id,
44
+ entry.timestamp,
45
+ entry.operation,
46
+ entry.provider,
47
+ entry.reference ?? null,
48
+ entry.providerId ?? null,
49
+ entry.status,
50
+ entry.amount ?? null,
51
+ entry.currency ?? null,
52
+ entry.durationMs ?? null,
53
+ entry.errorCode ?? null,
54
+ entry.errorMessage ?? null,
55
+ entry.metadata ? JSON.stringify(entry.metadata) : null,
56
+ ];
57
+ await opts.pool.query(sql, params);
58
+ },
59
+ async query(filter) {
60
+ const whereClauses = [];
61
+ const params = [];
62
+ let paramIndex = 1;
63
+ if (filter.reference !== undefined) {
64
+ whereClauses.push(`reference = $${paramIndex++}`);
65
+ params.push(filter.reference);
66
+ }
67
+ if (filter.provider !== undefined) {
68
+ whereClauses.push(`provider = $${paramIndex++}`);
69
+ params.push(filter.provider);
70
+ }
71
+ if (filter.status !== undefined) {
72
+ whereClauses.push(`status = $${paramIndex++}`);
73
+ params.push(filter.status);
74
+ }
75
+ if (filter.fromTimestamp !== undefined) {
76
+ whereClauses.push(`timestamp >= $${paramIndex++}`);
77
+ params.push(filter.fromTimestamp);
78
+ }
79
+ if (filter.toTimestamp !== undefined) {
80
+ whereClauses.push(`timestamp <= $${paramIndex++}`);
81
+ params.push(filter.toTimestamp);
82
+ }
83
+ const whereClause = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : '';
84
+ const limitClause = filter.limit !== undefined ? `LIMIT $${paramIndex++}` : '';
85
+ if (filter.limit !== undefined) {
86
+ params.push(filter.limit);
87
+ }
88
+ const sql = `SELECT
89
+ id, timestamp, operation, provider, reference, provider_id,
90
+ status, amount, currency, duration_ms, error_code, error_message, metadata
91
+ FROM ${fullTableName}
92
+ ${whereClause}
93
+ ORDER BY timestamp DESC
94
+ ${limitClause}`.trim();
95
+ const result = await opts.pool.query(sql, params);
96
+ return result.rows.map(row => ({
97
+ id: row.id,
98
+ timestamp: row.timestamp,
99
+ operation: row.operation,
100
+ provider: row.provider,
101
+ reference: row.reference ?? undefined,
102
+ providerId: row.provider_id ?? undefined,
103
+ status: row.status,
104
+ amount: row.amount !== null ? parseFloat(row.amount) : undefined,
105
+ currency: row.currency ?? undefined,
106
+ durationMs: row.duration_ms ?? undefined,
107
+ errorCode: row.error_code ?? undefined,
108
+ errorMessage: row.error_message ?? undefined,
109
+ metadata: row.metadata ?? undefined,
110
+ }));
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,7 @@
1
+ export interface PgQueryResult<T = any> {
2
+ rows: T[];
3
+ rowCount: number | null;
4
+ }
5
+ export interface PgPoolLike {
6
+ query<T = any>(sql: string, params?: unknown[]): Promise<PgQueryResult<T>>;
7
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -4,6 +4,7 @@
4
4
  import { PayBridge } from './index';
5
5
  import { Currency } from './types';
6
6
  import { RoutingStrategy } from './routing-types';
7
+ import type { LedgerStore } from './ledger';
7
8
  export interface ProviderWithMeta {
8
9
  instance: PayBridge;
9
10
  weight?: number;
@@ -16,3 +17,19 @@ export interface StrategyContext {
16
17
  export type Strategy = (providers: ProviderWithMeta[], context: StrategyContext, getRRIndex?: () => number) => ProviderWithMeta[];
17
18
  export declare const strategies: Record<RoutingStrategy, Strategy>;
18
19
  export declare function getStrategy(name: RoutingStrategy): Strategy;
20
+ export interface SuccessRateStrategyOptions {
21
+ ledger: LedgerStore;
22
+ windowMs?: number;
23
+ cacheTtlMs?: number;
24
+ minSampleSize?: number;
25
+ fallback?: 'cheapest' | 'fastest' | 'priority' | 'round-robin';
26
+ }
27
+ export interface SuccessRateStrategy {
28
+ order: (providers: ProviderWithMeta[]) => Promise<ProviderWithMeta[]>;
29
+ refresh: () => Promise<void>;
30
+ getRates: () => Map<string, {
31
+ successRate: number;
32
+ sampleSize: number;
33
+ }>;
34
+ }
35
+ export declare function createSuccessRateStrategy(opts: SuccessRateStrategyOptions): SuccessRateStrategy;
@@ -5,6 +5,7 @@
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.strategies = void 0;
7
7
  exports.getStrategy = getStrategy;
8
+ exports.createSuccessRateStrategy = createSuccessRateStrategy;
8
9
  exports.strategies = {
9
10
  cheapest: (providers, context) => {
10
11
  return [...providers].sort((a, b) => {
@@ -42,3 +43,72 @@ exports.strategies = {
42
43
  function getStrategy(name) {
43
44
  return exports.strategies[name];
44
45
  }
46
+ function createSuccessRateStrategy(opts) {
47
+ const windowMs = opts.windowMs ?? 24 * 60 * 60 * 1000;
48
+ const cacheTtlMs = opts.cacheTtlMs ?? 60 * 1000;
49
+ const minSampleSize = opts.minSampleSize ?? 10;
50
+ const fallbackName = opts.fallback ?? 'cheapest';
51
+ const fallbackStrategy = exports.strategies[fallbackName];
52
+ let cachedRates = new Map();
53
+ let lastRefreshTime = 0;
54
+ async function computeRates() {
55
+ const now = Date.now();
56
+ const fromTimestamp = new Date(now - windowMs).toISOString();
57
+ const entries = await opts.ledger.query({
58
+ fromTimestamp,
59
+ });
60
+ const providerStats = new Map();
61
+ for (const entry of entries) {
62
+ if (!providerStats.has(entry.provider)) {
63
+ providerStats.set(entry.provider, { total: 0, success: 0 });
64
+ }
65
+ const stats = providerStats.get(entry.provider);
66
+ stats.total++;
67
+ if (entry.status === 'success') {
68
+ stats.success++;
69
+ }
70
+ }
71
+ const rates = new Map();
72
+ for (const [provider, stats] of providerStats) {
73
+ rates.set(provider, {
74
+ successRate: stats.total > 0 ? stats.success / stats.total : 0,
75
+ sampleSize: stats.total,
76
+ });
77
+ }
78
+ return rates;
79
+ }
80
+ async function ensureFreshCache() {
81
+ const now = Date.now();
82
+ if (now - lastRefreshTime > cacheTtlMs) {
83
+ cachedRates = await computeRates();
84
+ lastRefreshTime = now;
85
+ }
86
+ }
87
+ return {
88
+ async order(providers) {
89
+ await ensureFreshCache();
90
+ const highConfidence = [];
91
+ const lowConfidence = [];
92
+ for (const provider of providers) {
93
+ const providerName = provider.instance.getProviderName();
94
+ const stats = cachedRates.get(providerName);
95
+ if (stats && stats.sampleSize >= minSampleSize) {
96
+ highConfidence.push({ provider, rate: stats.successRate });
97
+ }
98
+ else {
99
+ lowConfidence.push(provider);
100
+ }
101
+ }
102
+ highConfidence.sort((a, b) => b.rate - a.rate);
103
+ const sortedLowConfidence = fallbackStrategy(lowConfidence, { amount: 0, currency: 'USD' });
104
+ return [...highConfidence.map(h => h.provider), ...sortedLowConfidence];
105
+ },
106
+ async refresh() {
107
+ cachedRates = await computeRates();
108
+ lastRefreshTime = Date.now();
109
+ },
110
+ getRates() {
111
+ return new Map(cachedRates);
112
+ },
113
+ };
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paybridge",
3
- "version": "0.11.0",
3
+ "version": "1.0.0-rc.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",
@@ -49,7 +49,10 @@
49
49
  "command-line",
50
50
  "drift-detection",
51
51
  "monitoring",
52
- "api-validation"
52
+ "api-validation",
53
+ "postgres",
54
+ "success-rate",
55
+ "intelligent-routing"
53
56
  ],
54
57
  "author": "Kobie Wentzel",
55
58
  "license": "MIT",