paybridge 0.12.0 → 1.0.0-rc.2

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,76 @@ 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
+ ## Audit Reports
105
+
106
+ Your payment stack generates signals across silos: drift detection in one CLI command, reconciliation in another, success rates in third-party dashboards, latency buried in logs. **Audit reports** unify every observability feature into one comprehensive artifact.
107
+
108
+ ```bash
109
+ # Generate HTML report (default)
110
+ npx paybridge audit --window 30d --ledger-pg postgresql://localhost/db
111
+
112
+ # JSON for CI/CD pipelines
113
+ npx paybridge audit --format json --output - | jq '.summary.anomalyCounts.high' | \
114
+ xargs -I {} test {} -eq 0 || exit 1
115
+
116
+ # Markdown for email
117
+ npx paybridge audit --format md --output - | mail -s "Monthly Audit" finance@example.com
118
+
119
+ # Include reconciliation data
120
+ npx paybridge audit --reconcile-input expected.jsonl --window 7d
121
+ ```
122
+
123
+ ### What's Included
124
+
125
+ - **Success rate analytics** — per-provider success/failure/timeout counts with half-window comparison (detects degradation)
126
+ - **Latency metrics** — avg + p95 per provider, with anomaly detection (>2s/5s/10s thresholds)
127
+ - **Fee estimation** — calculated from actual transaction volume × provider fee structure
128
+ - **Drift events** — timeline of baseline captures per provider
129
+ - **Reconciliation** — missed webhook count + mismatch table (if `--reconcile-input` provided)
130
+ - **Anomaly detection** — success rate drops (>10% decline), consecutive failures (3+), high latency, PII in raw responses
131
+ - **Compliance flags** — scans metadata for PII leakage (email, card_number, iban, etc.)
132
+
133
+ ### Output Formats
134
+
135
+ - **HTML** — dark mode, print-to-PDF friendly (Cmd/Ctrl+P), collapsible per-provider deep-dive. The artifact a CFO opens in a browser.
136
+ - **Markdown** — copy-paste into Notion/Confluence/Slack. Clean GFM tables.
137
+ - **JSON** — machine-readable. Pipe to CI/CD gates, monitoring dashboards, or alerting systems.
138
+
139
+ ### Exit Codes
140
+
141
+ - `0` — no high-severity anomalies
142
+ - `1` — high-severity anomalies detected (fails CI builds)
143
+
144
+ ### Cron Integration
145
+
146
+ ```bash
147
+ # Daily 6 AM audit
148
+ 0 6 * * * cd /app && npx paybridge audit --window 7d --ledger-pg $DB_URL --output /var/reports/audit-$(date +\%Y-\%m-\%d).html
149
+ ```
150
+
151
+ ### Programmatic API
152
+
153
+ ```typescript
154
+ import { generateAuditReport, renderAuditAsHtml } from 'paybridge';
155
+ import { createPostgresLedgerStore } from 'paybridge';
156
+ import { Pool } from 'pg';
157
+
158
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
159
+ const ledger = createPostgresLedgerStore({ pool });
160
+
161
+ const report = await generateAuditReport({
162
+ providers: [
163
+ { name: 'stripe', capabilities: stripeProvider.getCapabilities() },
164
+ { name: 'paystack', capabilities: paystackProvider.getCapabilities() },
165
+ ],
166
+ ledger,
167
+ windowMs: 30 * 24 * 60 * 60 * 1000, // 30 days
168
+ });
169
+
170
+ const html = renderAuditAsHtml(report);
171
+ await sendEmail({ to: 'finance@company.com', html });
172
+ ```
173
+
104
174
  ## Reconciliation
105
175
 
106
176
  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.
@@ -0,0 +1,102 @@
1
+ import type { LedgerStore } from './ledger';
2
+ import type { DriftStore } from './cli/drift-store';
3
+ import type { ProviderCapabilities } from './routing-types';
4
+ import type { ReconcileResult } from './cli/reconcile-types';
5
+ export interface AuditInput {
6
+ providers: Array<{
7
+ name: string;
8
+ capabilities: ProviderCapabilities;
9
+ }>;
10
+ windowMs?: number;
11
+ ledger?: LedgerStore;
12
+ driftStore?: DriftStore;
13
+ reconcileResults?: ReconcileResult[];
14
+ generatedAt?: string;
15
+ }
16
+ export interface ProviderAuditSection {
17
+ name: string;
18
+ region?: string;
19
+ totalAttempts: number;
20
+ successRate: number | null;
21
+ avgLatencyMs: number | null;
22
+ p95LatencyMs: number | null;
23
+ failureCount: number;
24
+ rateLimitedCount: number;
25
+ timeoutCount: number;
26
+ estimatedFeesPaid: number;
27
+ estimatedFeeCurrency: string;
28
+ driftEvents: Array<{
29
+ at: string;
30
+ summary: string;
31
+ }>;
32
+ anomalies: AuditAnomaly[];
33
+ }
34
+ export type AuditAnomaly = {
35
+ type: 'success_rate_drop';
36
+ severity: 'low' | 'medium' | 'high';
37
+ description: string;
38
+ previousRate: number;
39
+ currentRate: number;
40
+ } | {
41
+ type: 'drift_detected';
42
+ severity: 'medium';
43
+ description: string;
44
+ detectedAt: string;
45
+ } | {
46
+ type: 'consecutive_failures';
47
+ severity: 'high';
48
+ description: string;
49
+ count: number;
50
+ } | {
51
+ type: 'high_latency';
52
+ severity: 'low' | 'medium' | 'high';
53
+ description: string;
54
+ p95Ms: number;
55
+ } | {
56
+ type: 'pii_in_raw';
57
+ severity: 'high';
58
+ description: string;
59
+ field: string;
60
+ };
61
+ export interface AuditReport {
62
+ generatedAt: string;
63
+ windowMs: number;
64
+ windowStart: string;
65
+ windowEnd: string;
66
+ summary: {
67
+ totalProviders: number;
68
+ totalAttempts: number;
69
+ overallSuccessRate: number | null;
70
+ totalEstimatedFees: number;
71
+ totalEstimatedFeesCurrency: string;
72
+ anomalyCounts: {
73
+ high: number;
74
+ medium: number;
75
+ low: number;
76
+ };
77
+ missedWebhookCount: number;
78
+ };
79
+ providers: ProviderAuditSection[];
80
+ reconciliation?: {
81
+ total: number;
82
+ match: number;
83
+ mismatch: number;
84
+ notFound: number;
85
+ error: number;
86
+ mismatches: Array<{
87
+ provider: string;
88
+ reference: string;
89
+ expected: string;
90
+ actual: string;
91
+ }>;
92
+ };
93
+ complianceFlags: Array<{
94
+ provider: string;
95
+ finding: string;
96
+ severity: 'high' | 'medium' | 'low';
97
+ }>;
98
+ }
99
+ export declare function generateAuditReport(input: AuditInput): Promise<AuditReport>;
100
+ export declare function renderAuditAsHtml(report: AuditReport): string;
101
+ export declare function renderAuditAsMarkdown(report: AuditReport): string;
102
+ export declare function renderAuditAsJson(report: AuditReport, pretty?: boolean): string;
@@ -0,0 +1,663 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateAuditReport = generateAuditReport;
4
+ exports.renderAuditAsHtml = renderAuditAsHtml;
5
+ exports.renderAuditAsMarkdown = renderAuditAsMarkdown;
6
+ exports.renderAuditAsJson = renderAuditAsJson;
7
+ const PII_KEYS = ['email', 'card_number', 'iban', 'phone', 'id_number', 'ssn', 'passport', 'cvv', 'tax_id'];
8
+ async function generateAuditReport(input) {
9
+ const windowMs = input.windowMs ?? 7 * 24 * 60 * 60 * 1000;
10
+ const generatedAt = input.generatedAt ?? new Date().toISOString();
11
+ const windowEnd = generatedAt;
12
+ const windowStart = new Date(new Date(generatedAt).getTime() - windowMs).toISOString();
13
+ const providerSections = [];
14
+ const complianceFlags = [];
15
+ let totalAttempts = 0;
16
+ let totalSuccess = 0;
17
+ let totalFees = 0;
18
+ let feeCurrencies = new Set();
19
+ for (const providerMeta of input.providers) {
20
+ const { name, capabilities } = providerMeta;
21
+ let entries = [];
22
+ if (input.ledger) {
23
+ entries = await input.ledger.query({
24
+ provider: name,
25
+ fromTimestamp: windowStart,
26
+ toTimestamp: windowEnd,
27
+ });
28
+ }
29
+ const totalProviderAttempts = entries.length;
30
+ const successCount = entries.filter((e) => e.status === 'success').length;
31
+ const failureCount = entries.filter((e) => e.status === 'failed').length;
32
+ const rateLimitedCount = entries.filter((e) => e.status === 'rate_limited').length;
33
+ const timeoutCount = entries.filter((e) => e.status === 'timeout').length;
34
+ const successRate = totalProviderAttempts > 0 ? successCount / totalProviderAttempts : null;
35
+ const durations = entries
36
+ .filter((e) => e.durationMs !== undefined && e.durationMs !== null)
37
+ .map((e) => e.durationMs);
38
+ const avgLatencyMs = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : null;
39
+ const p95LatencyMs = calculateP95(durations);
40
+ const successfulEntries = entries.filter((e) => e.status === 'success' && e.amount !== undefined);
41
+ let estimatedFeesPaid = 0;
42
+ for (const entry of successfulEntries) {
43
+ const amount = entry.amount;
44
+ const fee = capabilities.fees.fixed + (amount * capabilities.fees.percent) / 100;
45
+ estimatedFeesPaid += fee;
46
+ }
47
+ feeCurrencies.add(capabilities.fees.currency);
48
+ totalFees += estimatedFeesPaid;
49
+ const driftEvents = [];
50
+ if (input.driftStore) {
51
+ const baseline = await input.driftStore.load(name);
52
+ if (baseline && baseline.shape.capturedAt) {
53
+ const capturedTime = new Date(baseline.shape.capturedAt).getTime();
54
+ const windowStartTime = new Date(windowStart).getTime();
55
+ if (capturedTime >= windowStartTime) {
56
+ driftEvents.push({
57
+ at: baseline.shape.capturedAt,
58
+ summary: `Baseline snapshot captured for ${baseline.operation}`,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ const anomalies = [];
64
+ if (totalProviderAttempts > 0) {
65
+ const halfWindow = windowMs / 2;
66
+ const midpoint = new Date(new Date(windowStart).getTime() + halfWindow).toISOString();
67
+ const previousEntries = entries.filter((e) => e.timestamp < midpoint);
68
+ const currentEntries = entries.filter((e) => e.timestamp >= midpoint);
69
+ if (previousEntries.length >= 20 && currentEntries.length >= 20) {
70
+ const previousSuccessRate = previousEntries.filter((e) => e.status === 'success').length / previousEntries.length;
71
+ const currentSuccessRate = currentEntries.filter((e) => e.status === 'success').length / currentEntries.length;
72
+ const drop = previousSuccessRate - currentSuccessRate;
73
+ if (drop >= 0.30) {
74
+ anomalies.push({
75
+ type: 'success_rate_drop',
76
+ severity: 'high',
77
+ description: `Success rate dropped by ${(drop * 100).toFixed(1)}% in recent window`,
78
+ previousRate: previousSuccessRate,
79
+ currentRate: currentSuccessRate,
80
+ });
81
+ }
82
+ else if (drop >= 0.20) {
83
+ anomalies.push({
84
+ type: 'success_rate_drop',
85
+ severity: 'medium',
86
+ description: `Success rate dropped by ${(drop * 100).toFixed(1)}% in recent window`,
87
+ previousRate: previousSuccessRate,
88
+ currentRate: currentSuccessRate,
89
+ });
90
+ }
91
+ else if (drop >= 0.10) {
92
+ anomalies.push({
93
+ type: 'success_rate_drop',
94
+ severity: 'low',
95
+ description: `Success rate dropped by ${(drop * 100).toFixed(1)}% in recent window`,
96
+ previousRate: previousSuccessRate,
97
+ currentRate: currentSuccessRate,
98
+ });
99
+ }
100
+ }
101
+ const consecutiveFailures = detectConsecutiveFailures(entries);
102
+ if (consecutiveFailures >= 3) {
103
+ anomalies.push({
104
+ type: 'consecutive_failures',
105
+ severity: 'high',
106
+ description: `${consecutiveFailures} consecutive failures detected`,
107
+ count: consecutiveFailures,
108
+ });
109
+ }
110
+ if (p95LatencyMs !== null) {
111
+ if (p95LatencyMs > 10000) {
112
+ anomalies.push({
113
+ type: 'high_latency',
114
+ severity: 'high',
115
+ description: `p95 latency ${p95LatencyMs.toFixed(0)}ms exceeds 10s threshold`,
116
+ p95Ms: p95LatencyMs,
117
+ });
118
+ }
119
+ else if (p95LatencyMs > 5000) {
120
+ anomalies.push({
121
+ type: 'high_latency',
122
+ severity: 'medium',
123
+ description: `p95 latency ${p95LatencyMs.toFixed(0)}ms exceeds 5s threshold`,
124
+ p95Ms: p95LatencyMs,
125
+ });
126
+ }
127
+ else if (p95LatencyMs > 2000) {
128
+ anomalies.push({
129
+ type: 'high_latency',
130
+ severity: 'low',
131
+ description: `p95 latency ${p95LatencyMs.toFixed(0)}ms exceeds 2s threshold`,
132
+ p95Ms: p95LatencyMs,
133
+ });
134
+ }
135
+ }
136
+ const sampleEntries = entries.slice(0, Math.min(10, entries.length));
137
+ for (const entry of sampleEntries) {
138
+ if (entry.metadata && typeof entry.metadata === 'object') {
139
+ const raw = entry.metadata.raw;
140
+ if (raw && typeof raw === 'object') {
141
+ for (const key of PII_KEYS) {
142
+ if (hasKey(raw, key)) {
143
+ anomalies.push({
144
+ type: 'pii_in_raw',
145
+ severity: 'high',
146
+ description: `PII field '${key}' found in raw response metadata`,
147
+ field: key,
148
+ });
149
+ complianceFlags.push({
150
+ provider: name,
151
+ finding: `PII field '${key}' found in raw response metadata`,
152
+ severity: 'high',
153
+ });
154
+ break;
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ totalAttempts += totalProviderAttempts;
162
+ totalSuccess += successCount;
163
+ providerSections.push({
164
+ name,
165
+ region: capabilities.country,
166
+ totalAttempts: totalProviderAttempts,
167
+ successRate,
168
+ avgLatencyMs,
169
+ p95LatencyMs,
170
+ failureCount,
171
+ rateLimitedCount,
172
+ timeoutCount,
173
+ estimatedFeesPaid,
174
+ estimatedFeeCurrency: capabilities.fees.currency,
175
+ driftEvents,
176
+ anomalies,
177
+ });
178
+ }
179
+ const overallSuccessRate = totalAttempts > 0 ? totalSuccess / totalAttempts : null;
180
+ const anomalyCounts = { high: 0, medium: 0, low: 0 };
181
+ for (const section of providerSections) {
182
+ for (const anomaly of section.anomalies) {
183
+ anomalyCounts[anomaly.severity]++;
184
+ }
185
+ }
186
+ let reconciliation;
187
+ let missedWebhookCount = 0;
188
+ if (input.reconcileResults) {
189
+ const mismatches = input.reconcileResults
190
+ .filter((r) => r.classification === 'mismatch')
191
+ .map((r) => ({
192
+ provider: r.provider,
193
+ reference: r.reference,
194
+ expected: r.expectedStatus,
195
+ actual: r.actualStatus ?? 'unknown',
196
+ }));
197
+ missedWebhookCount = mismatches.length;
198
+ reconciliation = {
199
+ total: input.reconcileResults.length,
200
+ match: input.reconcileResults.filter((r) => r.classification === 'match').length,
201
+ mismatch: input.reconcileResults.filter((r) => r.classification === 'mismatch').length,
202
+ notFound: input.reconcileResults.filter((r) => r.classification === 'not-found').length,
203
+ error: input.reconcileResults.filter((r) => r.classification === 'error').length,
204
+ mismatches,
205
+ };
206
+ }
207
+ return {
208
+ generatedAt,
209
+ windowMs,
210
+ windowStart,
211
+ windowEnd,
212
+ summary: {
213
+ totalProviders: input.providers.length,
214
+ totalAttempts,
215
+ overallSuccessRate,
216
+ totalEstimatedFees: totalFees,
217
+ totalEstimatedFeesCurrency: feeCurrencies.size === 1 ? Array.from(feeCurrencies)[0] : 'mixed',
218
+ anomalyCounts,
219
+ missedWebhookCount,
220
+ },
221
+ providers: providerSections,
222
+ reconciliation,
223
+ complianceFlags,
224
+ };
225
+ }
226
+ function calculateP95(values) {
227
+ if (values.length === 0)
228
+ return null;
229
+ const sorted = [...values].sort((a, b) => a - b);
230
+ const index = Math.ceil(sorted.length * 0.95) - 1;
231
+ return sorted[Math.max(0, index)];
232
+ }
233
+ function detectConsecutiveFailures(entries) {
234
+ let maxConsecutive = 0;
235
+ let currentConsecutive = 0;
236
+ for (const entry of entries) {
237
+ if (entry.status === 'failed' || entry.status === 'timeout' || entry.status === 'rate_limited') {
238
+ currentConsecutive++;
239
+ maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
240
+ }
241
+ else {
242
+ currentConsecutive = 0;
243
+ }
244
+ }
245
+ return maxConsecutive;
246
+ }
247
+ function hasKey(obj, key) {
248
+ if (!obj || typeof obj !== 'object')
249
+ return false;
250
+ if (key in obj)
251
+ return true;
252
+ for (const k of Object.keys(obj)) {
253
+ if (typeof obj[k] === 'object' && hasKey(obj[k], key)) {
254
+ return true;
255
+ }
256
+ }
257
+ return false;
258
+ }
259
+ function renderAuditAsHtml(report) {
260
+ const { summary, providers, reconciliation, complianceFlags } = report;
261
+ const severityColor = (severity) => {
262
+ return severity === 'high' ? '#ff4444' : severity === 'medium' ? '#ffaa00' : '#ffdd00';
263
+ };
264
+ const anomalyRows = providers.flatMap((p) => p.anomalies.map((a) => ({ provider: p.name, anomaly: a }))).sort((a, b) => {
265
+ const order = { high: 0, medium: 1, low: 2 };
266
+ return order[a.anomaly.severity] - order[b.anomaly.severity];
267
+ });
268
+ return `<!DOCTYPE html>
269
+ <html lang="en">
270
+ <head>
271
+ <meta charset="UTF-8">
272
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
273
+ <title>PayBridge Audit Report</title>
274
+ <style>
275
+ * { margin: 0; padding: 0; box-sizing: border-box; }
276
+ body {
277
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
278
+ background: #0d1117;
279
+ color: #c9d1d9;
280
+ padding: 2rem;
281
+ line-height: 1.6;
282
+ }
283
+ .container { max-width: 1200px; margin: 0 auto; }
284
+ header {
285
+ background: linear-gradient(135deg, #161b22 0%, #1c2128 100%);
286
+ padding: 2rem;
287
+ border-radius: 12px;
288
+ margin-bottom: 2rem;
289
+ border: 1px solid #30363d;
290
+ position: relative;
291
+ }
292
+ header::before {
293
+ content: 'AUDIT REPORT — INTERNAL USE ONLY';
294
+ position: absolute;
295
+ top: 1rem;
296
+ right: 2rem;
297
+ font-size: 0.7rem;
298
+ color: #484f58;
299
+ letter-spacing: 1px;
300
+ }
301
+ h1 { color: #58a6ff; font-size: 2rem; margin-bottom: 0.5rem; }
302
+ .meta { color: #8b949e; font-size: 0.9rem; }
303
+ .summary {
304
+ display: grid;
305
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
306
+ gap: 1rem;
307
+ margin-bottom: 2rem;
308
+ }
309
+ .card {
310
+ background: #161b22;
311
+ border: 1px solid #30363d;
312
+ border-radius: 8px;
313
+ padding: 1.5rem;
314
+ transition: transform 0.2s;
315
+ }
316
+ .card:hover { transform: translateY(-2px); border-color: #58a6ff; }
317
+ .card-label { color: #8b949e; font-size: 0.85rem; margin-bottom: 0.5rem; }
318
+ .card-value { color: #58a6ff; font-size: 2rem; font-weight: bold; }
319
+ .card-unit { color: #8b949e; font-size: 1rem; }
320
+ table {
321
+ width: 100%;
322
+ border-collapse: collapse;
323
+ background: #161b22;
324
+ border-radius: 8px;
325
+ overflow: hidden;
326
+ margin-bottom: 2rem;
327
+ }
328
+ th {
329
+ background: #21262d;
330
+ color: #c9d1d9;
331
+ text-align: left;
332
+ padding: 1rem;
333
+ font-weight: 600;
334
+ border-bottom: 2px solid #30363d;
335
+ }
336
+ td {
337
+ padding: 0.75rem 1rem;
338
+ border-bottom: 1px solid #21262d;
339
+ }
340
+ tr:last-child td { border-bottom: none; }
341
+ .section { margin-bottom: 3rem; }
342
+ .section-title {
343
+ color: #58a6ff;
344
+ font-size: 1.5rem;
345
+ margin-bottom: 1rem;
346
+ padding-bottom: 0.5rem;
347
+ border-bottom: 2px solid #30363d;
348
+ }
349
+ .badge {
350
+ display: inline-block;
351
+ padding: 0.25rem 0.75rem;
352
+ border-radius: 12px;
353
+ font-size: 0.8rem;
354
+ font-weight: 600;
355
+ }
356
+ .badge-high { background: #ff4444; color: white; }
357
+ .badge-medium { background: #ffaa00; color: white; }
358
+ .badge-low { background: #ffdd00; color: #0d1117; }
359
+ details {
360
+ background: #161b22;
361
+ border: 1px solid #30363d;
362
+ border-radius: 8px;
363
+ padding: 1rem;
364
+ margin-bottom: 1rem;
365
+ }
366
+ summary {
367
+ cursor: pointer;
368
+ font-weight: 600;
369
+ color: #58a6ff;
370
+ user-select: none;
371
+ }
372
+ summary:hover { text-decoration: underline; }
373
+ .no-data { color: #8b949e; font-style: italic; }
374
+ footer {
375
+ text-align: center;
376
+ color: #484f58;
377
+ margin-top: 3rem;
378
+ padding-top: 2rem;
379
+ border-top: 1px solid #21262d;
380
+ font-size: 0.9rem;
381
+ }
382
+ @media print {
383
+ body { background: white; color: black; }
384
+ .card, table, details { background: white; border-color: #ddd; }
385
+ th { background: #f5f5f5; }
386
+ .badge-high { background: #ffcccc; color: #cc0000; }
387
+ .badge-medium { background: #ffe6cc; color: #cc6600; }
388
+ .badge-low { background: #ffffcc; color: #666600; }
389
+ header::before { color: #ccc; }
390
+ }
391
+ </style>
392
+ </head>
393
+ <body>
394
+ <div class="container">
395
+ <header>
396
+ <h1>PayBridge Audit Report</h1>
397
+ <div class="meta">
398
+ <div>Generated: ${new Date(report.generatedAt).toLocaleString()}</div>
399
+ <div>Window: ${formatDuration(report.windowMs)} (${report.windowStart.split('T')[0]} to ${report.windowEnd.split('T')[0]})</div>
400
+ </div>
401
+ </header>
402
+
403
+ <div class="summary">
404
+ <div class="card">
405
+ <div class="card-label">Total Providers</div>
406
+ <div class="card-value">${summary.totalProviders}</div>
407
+ </div>
408
+ <div class="card">
409
+ <div class="card-label">Total Attempts</div>
410
+ <div class="card-value">${summary.totalAttempts.toLocaleString()}</div>
411
+ </div>
412
+ <div class="card">
413
+ <div class="card-label">Success Rate</div>
414
+ <div class="card-value">${summary.overallSuccessRate !== null ? (summary.overallSuccessRate * 100).toFixed(1) : '—'}<span class="card-unit">%</span></div>
415
+ </div>
416
+ <div class="card">
417
+ <div class="card-label">Estimated Fees</div>
418
+ <div class="card-value">${summary.totalEstimatedFees.toFixed(2)} <span class="card-unit">${summary.totalEstimatedFeesCurrency}</span></div>
419
+ </div>
420
+ <div class="card">
421
+ <div class="card-label">Anomalies</div>
422
+ <div class="card-value">
423
+ <span style="color: #ff4444">${summary.anomalyCounts.high}</span> /
424
+ <span style="color: #ffaa00">${summary.anomalyCounts.medium}</span> /
425
+ <span style="color: #ffdd00">${summary.anomalyCounts.low}</span>
426
+ </div>
427
+ </div>
428
+ <div class="card">
429
+ <div class="card-label">Missed Webhooks</div>
430
+ <div class="card-value" style="color: ${summary.missedWebhookCount > 0 ? '#ff4444' : '#3fb950'}">
431
+ ${summary.missedWebhookCount}
432
+ </div>
433
+ </div>
434
+ </div>
435
+
436
+ <div class="section">
437
+ <h2 class="section-title">Provider Overview</h2>
438
+ <table>
439
+ <thead>
440
+ <tr>
441
+ <th>Provider</th>
442
+ <th>Region</th>
443
+ <th>Attempts</th>
444
+ <th>Success Rate</th>
445
+ <th>Avg Latency</th>
446
+ <th>p95 Latency</th>
447
+ <th>Fees Paid</th>
448
+ <th>Anomalies</th>
449
+ </tr>
450
+ </thead>
451
+ <tbody>
452
+ ${providers.map((p) => `
453
+ <tr>
454
+ <td><strong>${p.name}</strong></td>
455
+ <td>${p.region ?? '—'}</td>
456
+ <td>${p.totalAttempts.toLocaleString()}</td>
457
+ <td>${p.successRate !== null ? (p.successRate * 100).toFixed(1) + '%' : '—'}</td>
458
+ <td>${p.avgLatencyMs !== null ? p.avgLatencyMs.toFixed(0) + 'ms' : '—'}</td>
459
+ <td>${p.p95LatencyMs !== null ? p.p95LatencyMs.toFixed(0) + 'ms' : '—'}</td>
460
+ <td>${p.estimatedFeesPaid.toFixed(2)} ${p.estimatedFeeCurrency}</td>
461
+ <td>${p.anomalies.length > 0 ? p.anomalies.length : '—'}</td>
462
+ </tr>
463
+ `).join('')}
464
+ </tbody>
465
+ </table>
466
+ </div>
467
+
468
+ ${anomalyRows.length > 0 ? `
469
+ <div class="section">
470
+ <h2 class="section-title">Anomalies</h2>
471
+ <table>
472
+ <thead>
473
+ <tr>
474
+ <th>Severity</th>
475
+ <th>Provider</th>
476
+ <th>Type</th>
477
+ <th>Description</th>
478
+ </tr>
479
+ </thead>
480
+ <tbody>
481
+ ${anomalyRows.map(({ provider, anomaly }) => `
482
+ <tr>
483
+ <td><span class="badge badge-${anomaly.severity}">${anomaly.severity.toUpperCase()}</span></td>
484
+ <td>${provider}</td>
485
+ <td>${anomaly.type}</td>
486
+ <td>${anomaly.description}</td>
487
+ </tr>
488
+ `).join('')}
489
+ </tbody>
490
+ </table>
491
+ </div>
492
+ ` : ''}
493
+
494
+ ${reconciliation ? `
495
+ <div class="section">
496
+ <h2 class="section-title">Reconciliation</h2>
497
+ <div class="summary">
498
+ <div class="card">
499
+ <div class="card-label">Match</div>
500
+ <div class="card-value" style="color: #3fb950">${reconciliation.match}</div>
501
+ </div>
502
+ <div class="card">
503
+ <div class="card-label">Mismatch</div>
504
+ <div class="card-value" style="color: #ff4444">${reconciliation.mismatch}</div>
505
+ </div>
506
+ <div class="card">
507
+ <div class="card-label">Not Found</div>
508
+ <div class="card-value" style="color: #ffaa00">${reconciliation.notFound}</div>
509
+ </div>
510
+ <div class="card">
511
+ <div class="card-label">Error</div>
512
+ <div class="card-value" style="color: #ff4444">${reconciliation.error}</div>
513
+ </div>
514
+ </div>
515
+ ${reconciliation.mismatches.length > 0 ? `
516
+ <table>
517
+ <thead>
518
+ <tr>
519
+ <th>Provider</th>
520
+ <th>Reference</th>
521
+ <th>Expected</th>
522
+ <th>Actual</th>
523
+ </tr>
524
+ </thead>
525
+ <tbody>
526
+ ${reconciliation.mismatches.map((m) => `
527
+ <tr>
528
+ <td>${m.provider}</td>
529
+ <td><code>${m.reference}</code></td>
530
+ <td>${m.expected}</td>
531
+ <td>${m.actual}</td>
532
+ </tr>
533
+ `).join('')}
534
+ </tbody>
535
+ </table>
536
+ ` : '<p class="no-data">No mismatches detected.</p>'}
537
+ </div>
538
+ ` : ''}
539
+
540
+ ${complianceFlags.length > 0 ? `
541
+ <div class="section">
542
+ <h2 class="section-title">Compliance Flags</h2>
543
+ <table>
544
+ <thead>
545
+ <tr>
546
+ <th>Severity</th>
547
+ <th>Provider</th>
548
+ <th>Finding</th>
549
+ </tr>
550
+ </thead>
551
+ <tbody>
552
+ ${complianceFlags.map((f) => `
553
+ <tr>
554
+ <td><span class="badge badge-${f.severity}">${f.severity.toUpperCase()}</span></td>
555
+ <td>${f.provider}</td>
556
+ <td>${f.finding}</td>
557
+ </tr>
558
+ `).join('')}
559
+ </tbody>
560
+ </table>
561
+ </div>
562
+ ` : ''}
563
+
564
+ <div class="section">
565
+ <h2 class="section-title">Per-Provider Details</h2>
566
+ ${providers.map((p) => `
567
+ <details>
568
+ <summary>${p.name} — ${p.totalAttempts} attempts, ${p.successRate !== null ? (p.successRate * 100).toFixed(1) + '%' : 'N/A'} success</summary>
569
+ <div style="margin-top: 1rem;">
570
+ <p><strong>Failures:</strong> ${p.failureCount} (Rate Limited: ${p.rateLimitedCount}, Timeout: ${p.timeoutCount})</p>
571
+ <p><strong>Latency:</strong> Avg ${p.avgLatencyMs !== null ? p.avgLatencyMs.toFixed(0) : 'N/A'}ms, p95 ${p.p95LatencyMs !== null ? p.p95LatencyMs.toFixed(0) : 'N/A'}ms</p>
572
+ <p><strong>Drift Events:</strong> ${p.driftEvents.length > 0 ? p.driftEvents.map((d) => `${d.at} - ${d.summary}`).join(', ') : 'None'}</p>
573
+ <p><strong>Anomalies:</strong> ${p.anomalies.length > 0 ? p.anomalies.map((a) => a.description).join('; ') : 'None'}</p>
574
+ </div>
575
+ </details>
576
+ `).join('')}
577
+ </div>
578
+
579
+ <footer>
580
+ Generated by <strong>paybridge audit</strong><br>
581
+ Source: <a href="https://github.com/kobie3717/paybridge" style="color: #58a6ff;">github.com/kobie3717/paybridge</a>
582
+ </footer>
583
+ </div>
584
+ </body>
585
+ </html>`;
586
+ }
587
+ function renderAuditAsMarkdown(report) {
588
+ const { summary, providers, reconciliation, complianceFlags } = report;
589
+ let md = `# PayBridge Audit Report\n\n`;
590
+ md += `**Generated:** ${new Date(report.generatedAt).toLocaleString()}\n`;
591
+ md += `**Window:** ${formatDuration(report.windowMs)} (${report.windowStart.split('T')[0]} to ${report.windowEnd.split('T')[0]})\n\n`;
592
+ md += `## Executive Summary\n\n`;
593
+ md += `| Metric | Value |\n`;
594
+ md += `|--------|-------|\n`;
595
+ md += `| Total Providers | ${summary.totalProviders} |\n`;
596
+ md += `| Total Attempts | ${summary.totalAttempts.toLocaleString()} |\n`;
597
+ md += `| Success Rate | ${summary.overallSuccessRate !== null ? (summary.overallSuccessRate * 100).toFixed(1) + '%' : 'N/A'} |\n`;
598
+ md += `| Estimated Fees | ${summary.totalEstimatedFees.toFixed(2)} ${summary.totalEstimatedFeesCurrency} |\n`;
599
+ md += `| Anomalies (H/M/L) | ${summary.anomalyCounts.high} / ${summary.anomalyCounts.medium} / ${summary.anomalyCounts.low} |\n`;
600
+ md += `| Missed Webhooks | ${summary.missedWebhookCount} |\n\n`;
601
+ md += `## Provider Overview\n\n`;
602
+ md += `| Provider | Region | Attempts | Success Rate | Avg Latency | p95 Latency | Fees Paid | Anomalies |\n`;
603
+ md += `|----------|--------|----------|--------------|-------------|-------------|-----------|----------|\n`;
604
+ for (const p of providers) {
605
+ md += `| ${p.name} | ${p.region ?? 'N/A'} | ${p.totalAttempts} | ${p.successRate !== null ? (p.successRate * 100).toFixed(1) + '%' : 'N/A'} | `;
606
+ md += `${p.avgLatencyMs !== null ? p.avgLatencyMs.toFixed(0) + 'ms' : 'N/A'} | ${p.p95LatencyMs !== null ? p.p95LatencyMs.toFixed(0) + 'ms' : 'N/A'} | `;
607
+ md += `${p.estimatedFeesPaid.toFixed(2)} ${p.estimatedFeeCurrency} | ${p.anomalies.length || 'None'} |\n`;
608
+ }
609
+ md += `\n`;
610
+ const anomalyRows = providers.flatMap((p) => p.anomalies.map((a) => ({ provider: p.name, anomaly: a }))).sort((a, b) => {
611
+ const order = { high: 0, medium: 1, low: 2 };
612
+ return order[a.anomaly.severity] - order[b.anomaly.severity];
613
+ });
614
+ if (anomalyRows.length > 0) {
615
+ md += `## Anomalies\n\n`;
616
+ md += `| Severity | Provider | Type | Description |\n`;
617
+ md += `|----------|----------|------|-------------|\n`;
618
+ for (const { provider, anomaly } of anomalyRows) {
619
+ md += `| ${anomaly.severity.toUpperCase()} | ${provider} | ${anomaly.type} | ${anomaly.description} |\n`;
620
+ }
621
+ md += `\n`;
622
+ }
623
+ if (reconciliation) {
624
+ md += `## Reconciliation\n\n`;
625
+ md += `- **Match:** ${reconciliation.match}\n`;
626
+ md += `- **Mismatch:** ${reconciliation.mismatch}\n`;
627
+ md += `- **Not Found:** ${reconciliation.notFound}\n`;
628
+ md += `- **Error:** ${reconciliation.error}\n\n`;
629
+ if (reconciliation.mismatches.length > 0) {
630
+ md += `### Mismatches\n\n`;
631
+ md += `| Provider | Reference | Expected | Actual |\n`;
632
+ md += `|----------|-----------|----------|--------|\n`;
633
+ for (const m of reconciliation.mismatches) {
634
+ md += `| ${m.provider} | \`${m.reference}\` | ${m.expected} | ${m.actual} |\n`;
635
+ }
636
+ md += `\n`;
637
+ }
638
+ }
639
+ if (complianceFlags.length > 0) {
640
+ md += `## Compliance Flags\n\n`;
641
+ md += `| Severity | Provider | Finding |\n`;
642
+ md += `|----------|----------|----------|\n`;
643
+ for (const f of complianceFlags) {
644
+ md += `| ${f.severity.toUpperCase()} | ${f.provider} | ${f.finding} |\n`;
645
+ }
646
+ md += `\n`;
647
+ }
648
+ md += `---\n\n`;
649
+ md += `*Generated by **paybridge audit** — [github.com/kobie3717/paybridge](https://github.com/kobie3717/paybridge)*\n`;
650
+ return md;
651
+ }
652
+ function renderAuditAsJson(report, pretty = false) {
653
+ return JSON.stringify(report, null, pretty ? 2 : 0);
654
+ }
655
+ function formatDuration(ms) {
656
+ const days = Math.floor(ms / (24 * 60 * 60 * 1000));
657
+ if (days >= 1)
658
+ return `${days}d`;
659
+ const hours = Math.floor(ms / (60 * 60 * 1000));
660
+ if (hours >= 1)
661
+ return `${hours}h`;
662
+ return `${Math.floor(ms / (60 * 1000))}m`;
663
+ }
@@ -0,0 +1 @@
1
+ export declare function runAudit(args: string[]): Promise<void>;
@@ -0,0 +1,308 @@
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.runAudit = runAudit;
37
+ const node_fs_1 = require("node:fs");
38
+ const audit_report_1 = require("../../audit-report");
39
+ const drift_store_1 = require("../drift-store");
40
+ const postgres_ledger_1 = require("../../stores/postgres-ledger");
41
+ const runners_1 = require("../runners");
42
+ const index_1 = require("../../index");
43
+ const reconcile_1 = require("../reconcile");
44
+ const utils_1 = require("../utils");
45
+ function parseWindow(windowStr) {
46
+ const match = windowStr.match(/^(\d+)(d|h|m)$/);
47
+ if (!match) {
48
+ throw new Error(`Invalid window format: ${windowStr}. Use format like 7d, 24h, 60m`);
49
+ }
50
+ const value = parseInt(match[1], 10);
51
+ const unit = match[2];
52
+ switch (unit) {
53
+ case 'd':
54
+ return value * 24 * 60 * 60 * 1000;
55
+ case 'h':
56
+ return value * 60 * 60 * 1000;
57
+ case 'm':
58
+ return value * 60 * 1000;
59
+ default:
60
+ throw new Error(`Unknown unit: ${unit}`);
61
+ }
62
+ }
63
+ function parseArgs(args) {
64
+ const opts = {
65
+ format: 'html',
66
+ window: 7 * 24 * 60 * 60 * 1000,
67
+ driftDir: '.paybridge/drift-baseline',
68
+ };
69
+ for (let i = 0; i < args.length; i++) {
70
+ const arg = args[i];
71
+ switch (arg) {
72
+ case '--output':
73
+ opts.output = args[++i];
74
+ break;
75
+ case '--format':
76
+ const format = args[++i];
77
+ if (format !== 'html' && format !== 'md' && format !== 'json') {
78
+ throw new Error(`Invalid format: ${format}. Use html, md, or json`);
79
+ }
80
+ opts.format = format;
81
+ break;
82
+ case '--window':
83
+ opts.window = parseWindow(args[++i]);
84
+ break;
85
+ case '--providers':
86
+ opts.providers = args[++i].split(',').map((s) => s.trim());
87
+ break;
88
+ case '--ledger-pg':
89
+ opts.ledgerPg = args[++i];
90
+ break;
91
+ case '--drift-dir':
92
+ opts.driftDir = args[++i];
93
+ break;
94
+ case '--reconcile-input':
95
+ opts.reconcileInput = args[++i];
96
+ break;
97
+ case '-h':
98
+ case '--help':
99
+ printHelp();
100
+ process.exit(0);
101
+ break;
102
+ default:
103
+ throw new Error(`Unknown option: ${arg}`);
104
+ }
105
+ }
106
+ if (!opts.output) {
107
+ const timestamp = new Date().toISOString().split('T')[0];
108
+ const ext = opts.format === 'html' ? 'html' : opts.format === 'md' ? 'md' : 'json';
109
+ opts.output = `paybridge-audit-${timestamp}.${ext}`;
110
+ }
111
+ return opts;
112
+ }
113
+ function printHelp() {
114
+ console.log(`
115
+ paybridge audit — Generate comprehensive payment stack audit report
116
+
117
+ USAGE
118
+ paybridge audit [options]
119
+
120
+ OPTIONS
121
+ --output <file> Output file path (default: paybridge-audit-<date>.<ext>)
122
+ Use '-' for stdout
123
+ --format <type> Output format: html, md, json (default: html)
124
+ --window <duration> Analysis window: 7d, 30d, 90d, 24h, 1d (default: 7d)
125
+ --providers <list> Comma-separated provider names (default: all configured)
126
+ --ledger-pg <conn> PostgreSQL connection string for ledger data
127
+ --drift-dir <path> Drift baseline directory (default: .paybridge/drift-baseline)
128
+ --reconcile-input <file> Include reconciliation data from file (JSONL or CSV)
129
+ -h, --help Print this help
130
+
131
+ EXAMPLES
132
+ paybridge audit
133
+ paybridge audit --window 30d --format json --output report.json
134
+ paybridge audit --ledger-pg postgresql://user:pass@localhost/db
135
+ paybridge audit --output - --format md | mail -s "Audit" finance@example.com
136
+
137
+ EXIT CODES
138
+ 0 No high-severity anomalies
139
+ 1 High-severity anomalies detected
140
+
141
+ OUTPUT
142
+ HTML reports are print-to-PDF friendly (Cmd/Ctrl+P in browser).
143
+ JSON reports can be piped to CI/CD pipelines.
144
+
145
+ Docs: https://github.com/kobie3717/paybridge
146
+ `.trim());
147
+ }
148
+ async function runAudit(args) {
149
+ const opts = parseArgs(args);
150
+ const providerConfigs = opts.providers
151
+ ? runners_1.runners.filter((r) => opts.providers.includes(r.name))
152
+ : runners_1.runners.filter((r) => r.envRequired.every((e) => process.env[e]));
153
+ const providers = providerConfigs.map((r) => {
154
+ let credentials = {};
155
+ for (const envVar of r.envRequired) {
156
+ if (envVar === 'STRIPE_API_KEY')
157
+ credentials.apiKey = process.env.STRIPE_API_KEY;
158
+ else if (envVar === 'YOCO_API_KEY')
159
+ credentials.apiKey = process.env.YOCO_API_KEY;
160
+ else if (envVar === 'SOFTYCOMP_API_KEY') {
161
+ credentials.apiKey = process.env.SOFTYCOMP_API_KEY;
162
+ credentials.secretKey = process.env.SOFTYCOMP_SECRET_KEY;
163
+ }
164
+ else if (envVar === 'OZOW_API_KEY') {
165
+ credentials.apiKey = process.env.OZOW_API_KEY;
166
+ credentials.siteCode = process.env.OZOW_SITE_CODE;
167
+ credentials.privateKey = process.env.OZOW_PRIVATE_KEY;
168
+ }
169
+ else if (envVar === 'PAYFAST_MERCHANT_ID') {
170
+ credentials.merchantId = process.env.PAYFAST_MERCHANT_ID;
171
+ credentials.merchantKey = process.env.PAYFAST_MERCHANT_KEY;
172
+ credentials.passphrase = process.env.PAYFAST_PASSPHRASE;
173
+ }
174
+ else if (envVar === 'PAYSTACK_API_KEY')
175
+ credentials.apiKey = process.env.PAYSTACK_API_KEY;
176
+ else if (envVar === 'PEACH_ACCESS_TOKEN') {
177
+ credentials.apiKey = process.env.PEACH_ACCESS_TOKEN;
178
+ credentials.secretKey = process.env.PEACH_ENTITY_ID;
179
+ }
180
+ else if (envVar === 'FLUTTERWAVE_API_KEY')
181
+ credentials.apiKey = process.env.FLUTTERWAVE_API_KEY;
182
+ else if (envVar === 'ADYEN_API_KEY') {
183
+ credentials.apiKey = process.env.ADYEN_API_KEY;
184
+ credentials.merchantAccount = process.env.ADYEN_MERCHANT_ACCOUNT;
185
+ }
186
+ else if (envVar === 'MERCADOPAGO_ACCESS_TOKEN')
187
+ credentials.apiKey = process.env.MERCADOPAGO_ACCESS_TOKEN;
188
+ else if (envVar === 'RAZORPAY_KEY_ID') {
189
+ credentials.apiKey = process.env.RAZORPAY_KEY_ID;
190
+ credentials.secretKey = process.env.RAZORPAY_KEY_SECRET;
191
+ }
192
+ else if (envVar === 'MOLLIE_API_KEY')
193
+ credentials.apiKey = process.env.MOLLIE_API_KEY;
194
+ else if (envVar === 'SQUARE_ACCESS_TOKEN') {
195
+ credentials.apiKey = process.env.SQUARE_ACCESS_TOKEN;
196
+ credentials.locationId = process.env.SQUARE_LOCATION_ID;
197
+ }
198
+ else if (envVar === 'PESAPAL_CONSUMER_KEY') {
199
+ credentials.apiKey = process.env.PESAPAL_CONSUMER_KEY;
200
+ credentials.secretKey = process.env.PESAPAL_CONSUMER_SECRET;
201
+ credentials.notificationId = process.env.PESAPAL_NOTIFICATION_ID || 'dummy';
202
+ }
203
+ }
204
+ const pay = new index_1.PayBridge({
205
+ provider: r.name,
206
+ credentials,
207
+ sandbox: true,
208
+ });
209
+ return {
210
+ name: r.name,
211
+ capabilities: pay.provider.getCapabilities(),
212
+ };
213
+ });
214
+ if (providers.length === 0) {
215
+ console.error((0, utils_1.colorize)('Error: No providers configured. Set environment variables for at least one provider.', 'red'));
216
+ process.exit(1);
217
+ }
218
+ const input = {
219
+ providers,
220
+ windowMs: opts.window,
221
+ };
222
+ if (opts.ledgerPg) {
223
+ try {
224
+ // @ts-ignore - pg is an optional peer dependency
225
+ const pgModule = await Promise.resolve().then(() => __importStar(require('pg')));
226
+ const Pool = pgModule.default?.Pool || pgModule.Pool;
227
+ const pool = new Pool({ connectionString: opts.ledgerPg });
228
+ input.ledger = (0, postgres_ledger_1.createPostgresLedgerStore)({ pool });
229
+ }
230
+ catch (err) {
231
+ console.error((0, utils_1.colorize)(`Error: pg module not installed. Run: npm install pg`, 'red'));
232
+ process.exit(1);
233
+ }
234
+ }
235
+ if (opts.driftDir) {
236
+ input.driftStore = new drift_store_1.FileDriftStore(opts.driftDir);
237
+ }
238
+ if (opts.reconcileInput) {
239
+ const content = await node_fs_1.promises.readFile(opts.reconcileInput, 'utf-8');
240
+ const records = [];
241
+ const lines = content.trim().split('\n');
242
+ for (const line of lines) {
243
+ if (line.trim().startsWith('#') || !line.trim())
244
+ continue;
245
+ if (line.trim().startsWith('{')) {
246
+ const record = JSON.parse(line);
247
+ records.push(record);
248
+ }
249
+ else {
250
+ const parts = line.split(/\t|,/).map((s) => s.trim());
251
+ if (parts.length >= 3) {
252
+ records.push({
253
+ provider: parts[0],
254
+ reference: parts[1],
255
+ expectedStatus: parts[2],
256
+ });
257
+ }
258
+ }
259
+ }
260
+ const buildProvider = (providerName) => {
261
+ const runner = runners_1.runners.find((r) => r.name === providerName);
262
+ if (!runner)
263
+ throw new Error(`Unknown provider: ${providerName}`);
264
+ return new index_1.PayBridge({
265
+ provider: providerName,
266
+ credentials: {},
267
+ sandbox: true,
268
+ });
269
+ };
270
+ const hasCredsFor = (providerName) => {
271
+ const runner = runners_1.runners.find((r) => r.name === providerName);
272
+ return runner ? runner.envRequired.every((e) => process.env[e]) : false;
273
+ };
274
+ const { results } = await (0, reconcile_1.runReconcile)(records, { buildProvider, hasCredsFor });
275
+ input.reconcileResults = results;
276
+ }
277
+ const report = await (0, audit_report_1.generateAuditReport)(input);
278
+ let output;
279
+ switch (opts.format) {
280
+ case 'html':
281
+ output = (0, audit_report_1.renderAuditAsHtml)(report);
282
+ break;
283
+ case 'md':
284
+ output = (0, audit_report_1.renderAuditAsMarkdown)(report);
285
+ break;
286
+ case 'json':
287
+ output = (0, audit_report_1.renderAuditAsJson)(report, true);
288
+ break;
289
+ }
290
+ if (opts.output === '-') {
291
+ console.log(output);
292
+ }
293
+ else {
294
+ if (!opts.output) {
295
+ throw new Error('Output path is required');
296
+ }
297
+ await node_fs_1.promises.writeFile(opts.output, output, 'utf-8');
298
+ console.log((0, utils_1.colorize)(`Audit written to ${opts.output}`, 'green'));
299
+ console.log(`Total providers: ${report.summary.totalProviders}`);
300
+ console.log(`Anomalies: ${(0, utils_1.colorize)(`${report.summary.anomalyCounts.high}`, report.summary.anomalyCounts.high > 0 ? 'red' : 'green')} high, ${report.summary.anomalyCounts.medium} medium, ${report.summary.anomalyCounts.low} low`);
301
+ if (opts.format === 'html') {
302
+ console.log((0, utils_1.colorize)('Open in browser to review.', 'cyan'));
303
+ }
304
+ }
305
+ if (report.summary.anomalyCounts.high > 0) {
306
+ process.exit(1);
307
+ }
308
+ }
package/dist/cli/index.js CHANGED
@@ -8,6 +8,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
10
  const reconcile_1 = require("./commands/reconcile");
11
+ const audit_1 = require("./commands/audit");
11
12
  const utils_1 = require("./utils");
12
13
  async function main() {
13
14
  const [, , command, ...args] = process.argv;
@@ -33,6 +34,9 @@ async function main() {
33
34
  case 'reconcile':
34
35
  await (0, reconcile_1.runReconcileCommand)(args);
35
36
  break;
37
+ case 'audit':
38
+ await (0, audit_1.runAudit)(args);
39
+ break;
36
40
  case '-h':
37
41
  case '--help':
38
42
  case 'help':
package/dist/cli/utils.js CHANGED
@@ -73,6 +73,8 @@ COMMANDS
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
75
  reconcile [opts] Reconcile your DB against provider state (detects missed webhooks)
76
+ audit [opts] Generate comprehensive audit report (drift + reconcile +
77
+ success rates + fees + anomalies)
76
78
  help, -h, --help Print this help
77
79
  version, -v Print version
78
80
 
@@ -99,6 +101,8 @@ EXAMPLES
99
101
  paybridge drift-watch --interval 1h --webhook-url https://hooks.slack.com/...
100
102
  paybridge reconcile --input expected.jsonl
101
103
  psql -t -c "SELECT provider, reference, status FROM payments" | paybridge reconcile
104
+ paybridge audit --window 30d --ledger-pg postgresql://localhost/db
105
+ paybridge audit --output - --format md | mail -s "Audit" finance@example.com
102
106
 
103
107
  Docs: https://github.com/kobie3717/paybridge
104
108
  `.trim();
package/dist/index.d.ts CHANGED
@@ -26,6 +26,9 @@ export { createPostgresLedgerStore, type PostgresLedgerStoreOptions, getCreateTa
26
26
  export type { PgPoolLike, PgQueryResult } from './stores/postgres';
27
27
  export { runReconcile, type ReconcileDeps } from './cli/reconcile';
28
28
  export * from './cli/reconcile-types';
29
+ export * from './drift-detector';
30
+ export { FileDriftStore, type DriftStore } from './cli/drift-store';
31
+ export { generateAuditReport, renderAuditAsHtml, renderAuditAsMarkdown, renderAuditAsJson, type AuditInput, type AuditReport, type ProviderAuditSection, type AuditAnomaly, } from './audit-report';
29
32
  export declare class PayBridge {
30
33
  readonly provider: PaymentProvider;
31
34
  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.runReconcile = exports.getPostgresLedgerTableSql = exports.createPostgresLedgerStore = exports.createRedisLedgerStore = exports.createRedisIdempotencyStore = exports.createRedisCircuitBreakerStore = void 0;
23
+ exports.PayBridge = exports.renderAuditAsJson = exports.renderAuditAsMarkdown = exports.renderAuditAsHtml = exports.generateAuditReport = exports.FileDriftStore = 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");
@@ -60,6 +60,14 @@ Object.defineProperty(exports, "getPostgresLedgerTableSql", { enumerable: true,
60
60
  var reconcile_1 = require("./cli/reconcile");
61
61
  Object.defineProperty(exports, "runReconcile", { enumerable: true, get: function () { return reconcile_1.runReconcile; } });
62
62
  __exportStar(require("./cli/reconcile-types"), exports);
63
+ __exportStar(require("./drift-detector"), exports);
64
+ var drift_store_1 = require("./cli/drift-store");
65
+ Object.defineProperty(exports, "FileDriftStore", { enumerable: true, get: function () { return drift_store_1.FileDriftStore; } });
66
+ var audit_report_1 = require("./audit-report");
67
+ Object.defineProperty(exports, "generateAuditReport", { enumerable: true, get: function () { return audit_report_1.generateAuditReport; } });
68
+ Object.defineProperty(exports, "renderAuditAsHtml", { enumerable: true, get: function () { return audit_report_1.renderAuditAsHtml; } });
69
+ Object.defineProperty(exports, "renderAuditAsMarkdown", { enumerable: true, get: function () { return audit_report_1.renderAuditAsMarkdown; } });
70
+ Object.defineProperty(exports, "renderAuditAsJson", { enumerable: true, get: function () { return audit_report_1.renderAuditAsJson; } });
63
71
  class PayBridge {
64
72
  constructor(config) {
65
73
  this.provider = this.createProvider(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paybridge",
3
- "version": "0.12.0",
3
+ "version": "1.0.0-rc.2",
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",