paybridge 0.10.0 → 0.12.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/README.md CHANGED
@@ -101,6 +101,105 @@ Exit code 1 if drift detected, 0 if clean. Perfect for CI/CD pipelines or cron j
101
101
 
102
102
  The Square `/checkout/payment-links → /online-checkout/payment-links` endpoint change would have shipped silently to production. With `drift-check` running daily, you get a Slack alert the moment it happens.
103
103
 
104
+ ## Reconciliation
105
+
106
+ Webhooks can fail. Networks blip. Your server hiccups. Provider retries don't reach you. Without reconciliation, you discover missed webhooks when a customer complains their account wasn't credited.
107
+
108
+ PayBridge's **reconcile** command diffs your database against each provider's current state, catching payments where your local status doesn't match reality.
109
+
110
+ ### Quick Start
111
+
112
+ ```bash
113
+ # From JSONL file
114
+ echo '{"provider":"stripe","reference":"pay_001","expectedStatus":"pending"}' > expected.jsonl
115
+ npx paybridge reconcile --input expected.jsonl
116
+
117
+ # From SQL query (Postgres example)
118
+ psql -t -c "SELECT provider, reference, status AS \"expectedStatus\" FROM payments WHERE status='pending' AND created_at > now() - interval '24 hours'" \
119
+ | npx paybridge reconcile
120
+
121
+ # CSV format
122
+ cat payments.csv | npx paybridge reconcile
123
+ ```
124
+
125
+ ### Example Output
126
+
127
+ ```
128
+ [✓] stripe:pay_001 — completed (match)
129
+ [!] stripe:pay_002 — expected pending, actual completed (MISSED WEBHOOK)
130
+ [?] paystack:pay_003 — not-found (no provider record)
131
+ [✗] stripe:pay_004 — error (HTTP 503)
132
+ [ ] adyen:pay_005 — skipped (missing ADYEN_API_KEY)
133
+
134
+ Reconciled: 5
135
+ Match: 1
136
+ Mismatch (missed webhook): 1
137
+ Not found: 1
138
+ Error: 1
139
+ Skipped: 1
140
+ ```
141
+
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
+
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
+
104
203
  ## Quick Start
105
204
 
106
205
  > **Upgrading from 0.1 or 0.2?** See [docs/migration.md](docs/migration.md).
@@ -572,6 +671,34 @@ const router = new PayBridgeRouter({
572
671
 
573
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.
574
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
+
575
702
  ## Currency Handling
576
703
 
577
704
  PayBridge **always uses major currency units** (rands, dollars) in the API:
@@ -0,0 +1 @@
1
+ export declare function runReconcileCommand(args: string[]): Promise<void>;
@@ -0,0 +1,341 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runReconcileCommand = runReconcileCommand;
37
+ const fs = __importStar(require("node:fs"));
38
+ const readline = __importStar(require("node:readline"));
39
+ const index_1 = require("../../index");
40
+ const runners_1 = require("../runners");
41
+ const reconcile_1 = require("../reconcile");
42
+ const utils_1 = require("../utils");
43
+ function parseOptions(args) {
44
+ const opts = {};
45
+ for (let i = 0; i < args.length; i++) {
46
+ if (args[i] === '--input' && args[i + 1]) {
47
+ opts.input = args[i + 1];
48
+ i++;
49
+ }
50
+ else if (args[i] === '--json') {
51
+ opts.json = true;
52
+ }
53
+ else if (args[i] === '--webhook-url' && args[i + 1]) {
54
+ opts.webhookUrl = args[i + 1];
55
+ i++;
56
+ }
57
+ }
58
+ return opts;
59
+ }
60
+ async function parseInput(input) {
61
+ const lines = [];
62
+ if (input) {
63
+ const content = fs.readFileSync(input, 'utf8');
64
+ lines.push(...content.split('\n').filter((l) => l.trim()));
65
+ }
66
+ else {
67
+ const rl = readline.createInterface({
68
+ input: process.stdin,
69
+ output: process.stdout,
70
+ terminal: false,
71
+ });
72
+ for await (const line of rl) {
73
+ if (line.trim()) {
74
+ lines.push(line.trim());
75
+ }
76
+ }
77
+ }
78
+ if (lines.length === 0) {
79
+ return [];
80
+ }
81
+ const firstLine = lines[0];
82
+ const isCsv = /^provider\s*,\s*reference\s*,\s*expectedStatus/i.test(firstLine);
83
+ const records = [];
84
+ if (isCsv) {
85
+ for (let i = 1; i < lines.length; i++) {
86
+ const parts = lines[i].split(',').map((s) => s.trim());
87
+ if (parts.length >= 3) {
88
+ records.push({
89
+ provider: parts[0],
90
+ reference: parts[1],
91
+ expectedStatus: parts[2],
92
+ });
93
+ }
94
+ }
95
+ }
96
+ else {
97
+ for (const line of lines) {
98
+ try {
99
+ const obj = JSON.parse(line);
100
+ if (obj.provider && obj.reference && obj.expectedStatus) {
101
+ records.push({
102
+ provider: obj.provider,
103
+ reference: obj.reference,
104
+ expectedStatus: obj.expectedStatus,
105
+ });
106
+ }
107
+ }
108
+ catch (e) {
109
+ console.error(`${(0, utils_1.colorize)('[!]', 'yellow')} Skipping invalid JSON line: ${line}`);
110
+ }
111
+ }
112
+ }
113
+ return records;
114
+ }
115
+ function buildProvider(providerName) {
116
+ const runner = runners_1.runners.find((r) => r.name === providerName);
117
+ if (!runner) {
118
+ throw new Error(`Unknown provider: ${providerName}`);
119
+ }
120
+ const missing = runner.envRequired.filter((key) => !process.env[key]);
121
+ if (missing.length > 0) {
122
+ throw new Error(`Missing: ${missing.join(', ')}`);
123
+ }
124
+ switch (providerName) {
125
+ case 'stripe':
126
+ return new index_1.PayBridge({
127
+ provider: 'stripe',
128
+ credentials: { apiKey: process.env.STRIPE_API_KEY },
129
+ sandbox: true,
130
+ });
131
+ case 'paystack':
132
+ return new index_1.PayBridge({
133
+ provider: 'paystack',
134
+ credentials: { apiKey: process.env.PAYSTACK_API_KEY },
135
+ sandbox: true,
136
+ });
137
+ case 'flutterwave':
138
+ return new index_1.PayBridge({
139
+ provider: 'flutterwave',
140
+ credentials: { apiKey: process.env.FLUTTERWAVE_API_KEY },
141
+ sandbox: true,
142
+ });
143
+ case 'adyen':
144
+ return new index_1.PayBridge({
145
+ provider: 'adyen',
146
+ credentials: {
147
+ apiKey: process.env.ADYEN_API_KEY,
148
+ merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT,
149
+ },
150
+ sandbox: true,
151
+ });
152
+ case 'softycomp':
153
+ return new index_1.PayBridge({
154
+ provider: 'softycomp',
155
+ credentials: {
156
+ apiKey: process.env.SOFTYCOMP_API_KEY,
157
+ secretKey: process.env.SOFTYCOMP_SECRET_KEY,
158
+ },
159
+ sandbox: true,
160
+ });
161
+ case 'yoco':
162
+ return new index_1.PayBridge({
163
+ provider: 'yoco',
164
+ credentials: { apiKey: process.env.YOCO_API_KEY },
165
+ sandbox: true,
166
+ });
167
+ case 'ozow':
168
+ return new index_1.PayBridge({
169
+ provider: 'ozow',
170
+ credentials: {
171
+ apiKey: process.env.OZOW_API_KEY,
172
+ siteCode: process.env.OZOW_SITE_CODE,
173
+ privateKey: process.env.OZOW_PRIVATE_KEY,
174
+ },
175
+ sandbox: true,
176
+ });
177
+ case 'payfast':
178
+ return new index_1.PayBridge({
179
+ provider: 'payfast',
180
+ credentials: {
181
+ merchantId: process.env.PAYFAST_MERCHANT_ID,
182
+ merchantKey: process.env.PAYFAST_MERCHANT_KEY,
183
+ passphrase: process.env.PAYFAST_PASSPHRASE,
184
+ },
185
+ sandbox: true,
186
+ });
187
+ case 'peach':
188
+ return new index_1.PayBridge({
189
+ provider: 'peach',
190
+ credentials: {
191
+ apiKey: process.env.PEACH_ACCESS_TOKEN,
192
+ secretKey: process.env.PEACH_ENTITY_ID,
193
+ },
194
+ sandbox: true,
195
+ });
196
+ case 'mercadopago':
197
+ return new index_1.PayBridge({
198
+ provider: 'mercadopago',
199
+ credentials: { apiKey: process.env.MERCADOPAGO_ACCESS_TOKEN },
200
+ sandbox: true,
201
+ });
202
+ case 'razorpay':
203
+ return new index_1.PayBridge({
204
+ provider: 'razorpay',
205
+ credentials: {
206
+ apiKey: process.env.RAZORPAY_KEY_ID,
207
+ secretKey: process.env.RAZORPAY_KEY_SECRET,
208
+ },
209
+ sandbox: true,
210
+ });
211
+ case 'mollie':
212
+ return new index_1.PayBridge({
213
+ provider: 'mollie',
214
+ credentials: { apiKey: process.env.MOLLIE_API_KEY },
215
+ sandbox: true,
216
+ });
217
+ case 'square':
218
+ return new index_1.PayBridge({
219
+ provider: 'square',
220
+ credentials: {
221
+ apiKey: process.env.SQUARE_ACCESS_TOKEN,
222
+ locationId: process.env.SQUARE_LOCATION_ID,
223
+ },
224
+ sandbox: true,
225
+ });
226
+ case 'pesapal':
227
+ return new index_1.PayBridge({
228
+ provider: 'pesapal',
229
+ credentials: {
230
+ apiKey: process.env.PESAPAL_CONSUMER_KEY,
231
+ secretKey: process.env.PESAPAL_CONSUMER_SECRET,
232
+ notificationId: process.env.PESAPAL_NOTIFICATION_ID,
233
+ },
234
+ sandbox: true,
235
+ });
236
+ default:
237
+ throw new Error(`Provider ${providerName} not implemented in reconcile`);
238
+ }
239
+ }
240
+ function hasCredsFor(providerName) {
241
+ const runner = runners_1.runners.find((r) => r.name === providerName);
242
+ if (!runner) {
243
+ return false;
244
+ }
245
+ return runner.envRequired.every((key) => !!process.env[key]);
246
+ }
247
+ function printResult(result) {
248
+ const { provider, reference, classification, expectedStatus, actualStatus, errorMessage } = result;
249
+ let icon;
250
+ let message;
251
+ switch (classification) {
252
+ case 'match':
253
+ icon = (0, utils_1.colorize)('[✓]', 'green');
254
+ message = `${provider}:${reference} — ${actualStatus} (match)`;
255
+ break;
256
+ case 'mismatch':
257
+ icon = (0, utils_1.colorize)('[!]', 'yellow');
258
+ message = `${provider}:${reference} — expected ${expectedStatus}, actual ${actualStatus} (MISSED WEBHOOK)`;
259
+ break;
260
+ case 'not-found':
261
+ icon = (0, utils_1.colorize)('[?]', 'yellow');
262
+ message = `${provider}:${reference} — not-found (no provider record)`;
263
+ break;
264
+ case 'error':
265
+ icon = (0, utils_1.colorize)('[✗]', 'red');
266
+ message = `${provider}:${reference} — error (${errorMessage})`;
267
+ break;
268
+ case 'skipped':
269
+ icon = (0, utils_1.colorize)('[ ]', 'dim');
270
+ message = `${provider}:${reference} — skipped (${errorMessage})`;
271
+ break;
272
+ }
273
+ console.log(`${icon} ${message}`);
274
+ }
275
+ async function postWebhook(url, payload) {
276
+ const res = await fetch(url, {
277
+ method: 'POST',
278
+ headers: { 'Content-Type': 'application/json' },
279
+ body: JSON.stringify(payload),
280
+ });
281
+ if (!res.ok) {
282
+ throw new Error(`Webhook POST failed: ${res.status} ${res.statusText}`);
283
+ }
284
+ }
285
+ async function runReconcileCommand(args) {
286
+ const opts = parseOptions(args);
287
+ const records = await parseInput(opts.input);
288
+ if (records.length === 0) {
289
+ console.error('No records to reconcile. Provide input via stdin or --input <file>.');
290
+ process.exit(1);
291
+ }
292
+ const { results, summary } = await (0, reconcile_1.runReconcile)(records, { buildProvider, hasCredsFor }, {
293
+ onResult: opts.json ? undefined : printResult,
294
+ });
295
+ if (opts.json) {
296
+ for (const result of results) {
297
+ console.log(JSON.stringify(result));
298
+ }
299
+ console.log(JSON.stringify({ summary }));
300
+ }
301
+ else {
302
+ console.log(`\nReconciled: ${summary.total}`);
303
+ console.log(` Match: ${summary.match}`);
304
+ console.log(` Mismatch (missed webhook): ${summary.mismatch}`);
305
+ console.log(` Not found: ${summary.notFound}`);
306
+ console.log(` Error: ${summary.error}`);
307
+ console.log(` Skipped: ${summary.skipped}`);
308
+ }
309
+ if (opts.webhookUrl && summary.mismatch > 0) {
310
+ const mismatches = results.filter((r) => r.classification === 'mismatch');
311
+ const payload = {
312
+ totalReconciled: summary.total,
313
+ missed: summary.mismatch,
314
+ mismatches: mismatches.map((r) => ({
315
+ provider: r.provider,
316
+ reference: r.reference,
317
+ expected: r.expectedStatus,
318
+ actual: r.actualStatus,
319
+ })),
320
+ libVersion: '0.11.0',
321
+ };
322
+ try {
323
+ await postWebhook(opts.webhookUrl, payload);
324
+ if (!opts.json) {
325
+ console.log(`\n${(0, utils_1.colorize)('[webhook]', 'cyan')} Posted mismatch report to ${opts.webhookUrl}`);
326
+ }
327
+ }
328
+ catch (err) {
329
+ console.error(`${(0, utils_1.colorize)('[!]', 'yellow')} Webhook POST failed: ${err.message}`);
330
+ }
331
+ }
332
+ if (summary.mismatch > 0) {
333
+ process.exit(1);
334
+ }
335
+ else if (summary.error > 0 && summary.match === 0) {
336
+ process.exit(2);
337
+ }
338
+ else {
339
+ process.exit(0);
340
+ }
341
+ }
package/dist/cli/index.js CHANGED
@@ -7,6 +7,7 @@ const webhook_1 = require("./commands/webhook");
7
7
  const quote_1 = require("./commands/quote");
8
8
  const drift_1 = require("./commands/drift");
9
9
  const drift_watch_1 = require("./commands/drift-watch");
10
+ const reconcile_1 = require("./commands/reconcile");
10
11
  const utils_1 = require("./utils");
11
12
  async function main() {
12
13
  const [, , command, ...args] = process.argv;
@@ -29,6 +30,9 @@ async function main() {
29
30
  case 'drift-watch':
30
31
  await (0, drift_watch_1.runDriftWatch)(args);
31
32
  break;
33
+ case 'reconcile':
34
+ await (0, reconcile_1.runReconcileCommand)(args);
35
+ break;
32
36
  case '-h':
33
37
  case '--help':
34
38
  case 'help':
@@ -0,0 +1,22 @@
1
+ export type CanonicalStatus = 'completed' | 'pending' | 'failed' | 'cancelled' | 'refunded' | 'unknown';
2
+ export interface ReconcileRecord {
3
+ provider: string;
4
+ reference: string;
5
+ expectedStatus: CanonicalStatus | string;
6
+ }
7
+ export interface ReconcileResult {
8
+ provider: string;
9
+ reference: string;
10
+ expectedStatus: string;
11
+ actualStatus?: string;
12
+ classification: 'match' | 'mismatch' | 'not-found' | 'error' | 'skipped';
13
+ errorMessage?: string;
14
+ }
15
+ export interface ReconcileSummary {
16
+ total: number;
17
+ match: number;
18
+ mismatch: number;
19
+ notFound: number;
20
+ error: number;
21
+ skipped: number;
22
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,12 @@
1
+ import { PayBridge } from '../index';
2
+ import { ReconcileRecord, ReconcileResult, ReconcileSummary } from './reconcile-types';
3
+ export interface ReconcileDeps {
4
+ buildProvider: (providerName: string) => PayBridge;
5
+ hasCredsFor: (providerName: string) => boolean;
6
+ }
7
+ export declare function runReconcile(records: ReconcileRecord[], deps: ReconcileDeps, opts?: {
8
+ onResult?: (r: ReconcileResult) => void;
9
+ }): Promise<{
10
+ results: ReconcileResult[];
11
+ summary: ReconcileSummary;
12
+ }>;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runReconcile = runReconcile;
4
+ async function runReconcile(records, deps, opts = {}) {
5
+ const providerCache = new Map();
6
+ const results = [];
7
+ const summary = {
8
+ total: records.length,
9
+ match: 0,
10
+ mismatch: 0,
11
+ notFound: 0,
12
+ error: 0,
13
+ skipped: 0,
14
+ };
15
+ for (const record of records) {
16
+ if (!deps.hasCredsFor(record.provider)) {
17
+ const result = {
18
+ provider: record.provider,
19
+ reference: record.reference,
20
+ expectedStatus: record.expectedStatus,
21
+ classification: 'skipped',
22
+ errorMessage: 'missing credentials',
23
+ };
24
+ results.push(result);
25
+ summary.skipped++;
26
+ opts.onResult?.(result);
27
+ continue;
28
+ }
29
+ let pay;
30
+ if (providerCache.has(record.provider)) {
31
+ pay = providerCache.get(record.provider);
32
+ }
33
+ else {
34
+ try {
35
+ pay = deps.buildProvider(record.provider);
36
+ providerCache.set(record.provider, pay);
37
+ }
38
+ catch (err) {
39
+ const result = {
40
+ provider: record.provider,
41
+ reference: record.reference,
42
+ expectedStatus: record.expectedStatus,
43
+ classification: 'error',
44
+ errorMessage: err.message || String(err),
45
+ };
46
+ results.push(result);
47
+ summary.error++;
48
+ opts.onResult?.(result);
49
+ continue;
50
+ }
51
+ }
52
+ try {
53
+ const payment = await pay.getPayment(record.reference);
54
+ const actualStatus = payment.status;
55
+ const normalized = normalizeStatus(record.expectedStatus);
56
+ const match = normalized === actualStatus;
57
+ const result = {
58
+ provider: record.provider,
59
+ reference: record.reference,
60
+ expectedStatus: record.expectedStatus,
61
+ actualStatus,
62
+ classification: match ? 'match' : 'mismatch',
63
+ };
64
+ results.push(result);
65
+ if (match) {
66
+ summary.match++;
67
+ }
68
+ else {
69
+ summary.mismatch++;
70
+ }
71
+ opts.onResult?.(result);
72
+ }
73
+ catch (err) {
74
+ const isNotFound = err.message?.includes('not found') ||
75
+ err.message?.includes('404') ||
76
+ err.message?.includes('No payment found');
77
+ const result = {
78
+ provider: record.provider,
79
+ reference: record.reference,
80
+ expectedStatus: record.expectedStatus,
81
+ classification: isNotFound ? 'not-found' : 'error',
82
+ errorMessage: err.message || String(err),
83
+ };
84
+ results.push(result);
85
+ if (isNotFound) {
86
+ summary.notFound++;
87
+ }
88
+ else {
89
+ summary.error++;
90
+ }
91
+ opts.onResult?.(result);
92
+ }
93
+ }
94
+ return { results, summary };
95
+ }
96
+ function normalizeStatus(status) {
97
+ const lower = status.toLowerCase();
98
+ if (['completed', 'pending', 'failed', 'cancelled', 'refunded'].includes(lower)) {
99
+ return lower;
100
+ }
101
+ return 'unknown';
102
+ }
package/dist/cli/utils.js CHANGED
@@ -72,6 +72,7 @@ COMMANDS
72
72
  quote <p> [opts] Get a crypto on/off-ramp quote
73
73
  drift-check [opts] Capture/compare provider response shapes (drift detection)
74
74
  drift-watch [opts] Run drift-check on a loop (long-running monitor)
75
+ reconcile [opts] Reconcile your DB against provider state (detects missed webhooks)
75
76
  help, -h, --help Print this help
76
77
  version, -v Print version
77
78
 
@@ -96,6 +97,8 @@ EXAMPLES
96
97
  paybridge drift-check --capture
97
98
  paybridge drift-check stripe
98
99
  paybridge drift-watch --interval 1h --webhook-url https://hooks.slack.com/...
100
+ paybridge reconcile --input expected.jsonl
101
+ psql -t -c "SELECT provider, reference, status FROM payments" | paybridge reconcile
99
102
 
100
103
  Docs: https://github.com/kobie3717/paybridge
101
104
  `.trim();
package/dist/index.d.ts CHANGED
@@ -22,6 +22,10 @@ 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';
27
+ export { runReconcile, type ReconcileDeps } from './cli/reconcile';
28
+ export * from './cli/reconcile-types';
25
29
  export declare class PayBridge {
26
30
  readonly provider: PaymentProvider;
27
31
  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.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,12 @@ 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; } });
60
+ var reconcile_1 = require("./cli/reconcile");
61
+ Object.defineProperty(exports, "runReconcile", { enumerable: true, get: function () { return reconcile_1.runReconcile; } });
62
+ __exportStar(require("./cli/reconcile-types"), exports);
57
63
  class PayBridge {
58
64
  constructor(config) {
59
65
  this.provider = this.createProvider(config);
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.10.0",
3
+ "version": "0.12.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",
@@ -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",