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 +127 -0
- package/dist/cli/commands/reconcile.d.ts +1 -0
- package/dist/cli/commands/reconcile.js +341 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/reconcile-types.d.ts +22 -0
- package/dist/cli/reconcile-types.js +2 -0
- package/dist/cli/reconcile.d.ts +12 -0
- package/dist/cli/reconcile.js +102 -0
- package/dist/cli/utils.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -1
- package/dist/router.d.ts +2 -1
- package/dist/router.js +26 -14
- package/dist/routing-types.d.ts +1 -1
- package/dist/stores/postgres-ledger.d.ts +9 -0
- package/dist/stores/postgres-ledger.js +113 -0
- package/dist/stores/postgres.d.ts +7 -0
- package/dist/stores/postgres.js +2 -0
- package/dist/strategies.d.ts +17 -0
- package/dist/strategies.js +70 -0
- package/package.json +5 -2
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,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
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
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) {
|
package/dist/routing-types.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/strategies.d.ts
CHANGED
|
@@ -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;
|
package/dist/strategies.js
CHANGED
|
@@ -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.
|
|
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",
|