From c003bc03db3118730d00e211974bf80f125ffbe7 Mon Sep 17 00:00:00 2001 From: ijeoma270 Date: Sat, 17 Oct 2026 22:16:25 +0100 Subject: [PATCH 1/4] fix: improve webhook dedup with mark-processed and monitoring (#1020) --- backend/src/services/webhooks/replay.ts | 86 ++++++++++++++++++++----- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/backend/src/services/webhooks/replay.ts b/backend/src/services/webhooks/replay.ts index 93e3b9d8..0f93513c 100644 --- a/backend/src/services/webhooks/replay.ts +++ b/backend/src/services/webhooks/replay.ts @@ -1,31 +1,85 @@ -/** In-memory replay protection; swap for Redis in multi-instance deploys. */ -const seenEventIds = new Map(); -const DEFAULT_TTL_MS = 5 * 60 * 1000; +/** + * Webhook deduplication by provider event ID (#1020). + * + * When a payment provider retries a webhook (due to timeout, network error, + * or its own retry policy), the same event arrives multiple times. Without + * deduplication, each retry triggers a fresh processing run — double-charging + * customers, sending duplicate notifications, or corrupting state. + * + * This module tracks processed event IDs and ignores duplicates within a + * configurable retention window. + */ -export function isReplayEvent(eventId: string, ttlMs = DEFAULT_TTL_MS): boolean { +/** In-memory processed-event store; swap for Redis/DB in multi-instance deploys. */ +const processedEvents = new Map(); + +/** How long to retain processed event IDs (default: 24 hours). */ +const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000; + +/** Maximum entries to retain before aggressive pruning. */ +const MAX_ENTRIES = 50_000; + +/** + * Check whether an event has already been processed. + * Returns true if this is a duplicate (should be ignored). + */ +export function isDuplicateEvent( + provider: string, + eventId: string, + retentionMs: number = DEFAULT_RETENTION_MS +): boolean { + const key = `${provider}:${eventId}`; const now = Date.now(); - pruneExpired(now, ttlMs); - if (seenEventIds.has(eventId)) { + + pruneExpired(now, retentionMs); + + if (processedEvents.has(key)) { return true; } - seenEventIds.set(eventId, now + ttlMs); + + processedEvents.set(key, now + retentionMs); return false; } -function pruneExpired(now: number, ttlMs: number): void { - for (const [id, expires] of seenEventIds) { +/** + * Mark an event as processed (called after successful handling). + */ +export function markEventProcessed( + provider: string, + eventId: string, + retentionMs: number = DEFAULT_RETENTION_MS +): void { + const key = `${provider}:${eventId}`; + processedEvents.set(key, Date.now() + retentionMs); +} + +/** + * Check if an event was already processed without marking it. + */ +export function wasEventProcessed(provider: string, eventId: string): boolean { + return processedEvents.has(`${provider}:${eventId}`); +} + +function pruneExpired(now: number, retentionMs: number): void { + for (const [id, expires] of processedEvents) { if (expires < now) { - seenEventIds.delete(id); + processedEvents.delete(id); } } - if (seenEventIds.size > 10_000) { - const cutoff = now - ttlMs; - for (const [id, expires] of seenEventIds) { - if (expires < cutoff) seenEventIds.delete(id); + if (processedEvents.size > MAX_ENTRIES) { + const cutoff = now - retentionMs; + for (const [id, expires] of processedEvents) { + if (expires < cutoff) processedEvents.delete(id); } } } -export function clearReplayCache(): void { - seenEventIds.clear(); +/** Clear the processed-event cache (for testing). */ +export function clearProcessedEvents(): void { + processedEvents.clear(); +} + +/** Get the count of tracked events (for monitoring). */ +export function getProcessedEventCount(): number { + return processedEvents.size; } From 4cd6840ce607b17cd45a55240ef4422e404f23cd Mon Sep 17 00:00:00 2001 From: ijeoma270 Date: Sat, 17 Oct 2026 22:16:43 +0100 Subject: [PATCH 2/4] docs: add gateway integration guide (#1018) --- docs/GATEWAY_INTEGRATION.md | 118 ++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/GATEWAY_INTEGRATION.md diff --git a/docs/GATEWAY_INTEGRATION.md b/docs/GATEWAY_INTEGRATION.md new file mode 100644 index 00000000..dd176566 --- /dev/null +++ b/docs/GATEWAY_INTEGRATION.md @@ -0,0 +1,118 @@ +# Gateway Integration Guide + +This guide walks through adding a new payment gateway to AgenticPay. + +## Integration Checklist + +### 1. Implement the Gateway Adapter + +Create a new file in `backend/src/integrations/` implementing the `GatewayAdapter` interface: + +```typescript +import type { GatewayAdapter, PaymentRequest, PaymentResult } from './gateway-adapter'; + +export class MyGatewayAdapter implements GatewayAdapter { + readonly name = 'my-gateway'; + + async createPayment(request: PaymentRequest): Promise { + // 1. Validate the request + // 2. Call the provider API + // 3. Return a normalized PaymentResult + } + + async getPaymentStatus(externalId: string): Promise { + // Query the provider for current status + } + + async refund(externalId: string, amount?: number): Promise { + // Process a full or partial refund + } +} +``` + +### 2. Register the Adapter + +Add your adapter to the gateway registry in `backend/src/integrations/gateway-adapter.ts`: + +```typescript +import { MyGatewayAdapter } from './my-gateway.js'; + +export const gateways = { + stripe: new StripeAdapter(), + paypal: new PaypalAdapter(), + 'my-gateway': new MyGatewayAdapter(), +}; +``` + +### 3. Handle Webhooks + +Create a webhook verifier in `backend/src/services/webhooks/providers.ts`: + +```typescript +export function verifyMyGatewayWebhook( + req: Request, + rawBody: string +): ProviderVerificationResult { + // 1. Extract the signature from headers + // 2. Verify HMAC/signature against the raw body + // 3. Return { isValid, eventId, payload, error? } +} +``` + +Register it in `webhookVerification.ts`: + +```typescript +const providerVerifiers: Record = { + stripe: verifyStripeProviderWebhook, + paypal: verifyPaypalProviderWebhook, + github: verifyGithubProviderWebhook, + custom: verifyCustomProviderWebhook, + 'my-gateway': verifyMyGatewayWebhook, +}; +``` + +### 4. Environment Variables + +Add required secrets to `ENV_VARS.md` and the deployment configuration: + +| Variable | Description | Required | +|---|---|---| +| `MYGATEWAY_API_KEY` | API key for the gateway | Yes | +| `MYGATEWAY_WEBHOOK_SECRET` | Webhook signing secret | Yes | +| `MYGATEWAY_BASE_URL` | API base URL (sandbox vs production) | No | + +### 5. Test Strategy + +#### Unit Tests +- [ ] Adapter `createPayment` with valid/invalid requests +- [ ] Adapter `getPaymentStatus` for all status values +- [ ] Webhook signature verification (valid, invalid, missing) +- [ ] Webhook deduplication (same event ID twice) + +#### Integration Tests +- [ ] End-to-end payment flow (create → confirm → refund) +- [ ] Webhook processing pipeline (receive → verify → process → respond) +- [ ] Error handling (provider timeout, rate limit, auth failure) + +#### Sandbox Testing +- [ ] Test all payment methods in the provider's sandbox +- [ ] Verify webhook delivery and signature verification +- [ ] Test edge cases: expired cards, insufficient funds, network errors + +### 6. Documentation + +- [ ] Add gateway to `README.md` supported gateways table +- [ ] Document provider-specific quirks in `backend/docs/` +- [ ] Add sandbox setup instructions + +## Interface Reference + +See `backend/src/integrations/gateway-adapter.ts` for the full `GatewayAdapter` interface and `backend/src/services/webhooks/providers.ts` for the `ProviderVerificationResult` type. + +## Security Considerations + +- Never log API keys or webhook secrets +- Always verify webhook signatures before processing +- Use idempotency keys for payment creation +- Validate all amounts and currency codes server-side +- Rate-limit webhook endpoints per provider From 548b60bd8f5993bc994c69489d2b85309ad3d87c Mon Sep 17 00:00:00 2001 From: ijeoma270 Date: Sat, 17 Oct 2026 22:17:01 +0100 Subject: [PATCH 3/4] docs: add sandbox mode guide (#1019) --- docs/SANDBOX_MODE.md | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/SANDBOX_MODE.md diff --git a/docs/SANDBOX_MODE.md b/docs/SANDBOX_MODE.md new file mode 100644 index 00000000..50b1cd09 --- /dev/null +++ b/docs/SANDBOX_MODE.md @@ -0,0 +1,61 @@ +# Sandbox Mode + +AgenticPay supports a uniform sandbox mode across all payment gateways. Sandbox mode lets you test the full payment flow without moving real funds. + +## Enabling Sandbox Mode + +Sandbox mode is **opt-in per API key**. Set the `sandbox` flag when creating an API key: + +```json +POST /api/keys +{ + "name": "test-key", + "sandbox": true +} +``` + +When `sandbox: true`: +- All payment requests are routed to the gateway's sandbox/test environment +- No real funds are moved +- Webhooks are simulated or delivered from the sandbox +- Transaction records are tagged with `sandbox: true` + +## Per-Gateway Sandbox Behavior + +| Gateway | Sandbox Endpoint | Test Cards | Webhooks | +|---|---|---|---| +| Stripe | `https://api.stripe.com` (test mode key) | `4242 4242 4242 4242` | Real webhook delivery | +| PayPal | `https://api-m.sandbox.paypal.com` | Sandbox buyer accounts | Real webhook delivery | +| Custom | Configured `SANDBOX_BASE_URL` | Depends on provider | Manual or simulated | + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `SANDBOX_MODE` | Global sandbox override (for CI) | `false` | +| `SANDBOX_WEBHOOK_SECRET` | Secret for simulated webhooks | Random per boot | + +## Limitations + +- **No real payouts**: Sandbox transactions are never settled to real bank accounts +- **Simulated timing**: Some providers have different timing in sandbox (instant confirmations, etc.) +- **Limited error simulation**: Not all error paths can be triggered in sandbox +- **Data isolation**: Sandbox data is stored separately and can be cleared via `POST /api/sandbox/reset` +- **Rate limits**: Sandbox APIs may have different (often higher) rate limits than production + +## Testing Checklist + +- [ ] Create a sandbox API key +- [ ] Process a test payment with a test card +- [ ] Verify webhook delivery and processing +- [ ] Test a refund flow +- [ ] Confirm no real funds were moved +- [ ] Test error handling (declined card, expired card) +- [ ] Verify sandbox data is isolated from production + +## Switching to Production + +1. Create a new API key with `sandbox: false` +2. Use production API keys from the gateway provider +3. Update webhook endpoints to production URLs +4. Run a small real-amount test before full launch From 3edbf68931421efd25ee3f1c571f953dd2f71127 Mon Sep 17 00:00:00 2001 From: ijeoma270 Date: Sat, 17 Oct 2026 22:17:29 +0100 Subject: [PATCH 4/4] feat: add reconciliation report export CSV/JSON (#1017) --- backend/src/services/reconciliation/export.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 backend/src/services/reconciliation/export.ts diff --git a/backend/src/services/reconciliation/export.ts b/backend/src/services/reconciliation/export.ts new file mode 100644 index 00000000..da4bf6bf --- /dev/null +++ b/backend/src/services/reconciliation/export.ts @@ -0,0 +1,120 @@ +/** + * Reconciliation report export in CSV and JSON formats (#1017). + * + * Generates scheduled reconciliation reports with totals and references + * for finance teams. + */ +import { createObjectCsvWriter } from 'csv-writer'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { createModuleLogger } from '../middleware/logger.js'; + +const logger = createModuleLogger('reconciliation-export'); + +export interface ReconciliationEntry { + reference: string; + provider: string; + externalId: string; + amount: number; + currency: string; + status: string; + expectedAmount: number; + discrepancy: number; + timestamp: string; +} + +export interface ReconciliationReport { + reportId: string; + generatedAt: string; + periodStart: string; + periodEnd: string; + totals: { + entryCount: number; + totalAmount: number; + totalExpected: number; + totalDiscrepancy: number; + byProvider: Record; + byStatus: Record; + }; + entries: ReconciliationEntry[]; +} + +/** + * Export a reconciliation report to CSV. + */ +export async function exportCsv( + report: ReconciliationReport, + outputPath: string +): Promise { + const csvWriter = createObjectCsvWriter({ + path: outputPath, + header: [ + { id: 'reference', title: 'Reference' }, + { id: 'provider', title: 'Provider' }, + { id: 'externalId', title: 'External ID' }, + { id: 'amount', title: 'Amount' }, + { id: 'currency', title: 'Currency' }, + { id: 'status', title: 'Status' }, + { id: 'expectedAmount', title: 'Expected Amount' }, + { id: 'discrepancy', title: 'Discrepancy' }, + { id: 'timestamp', title: 'Timestamp' }, + ], + }); + + await csvWriter.writeRecords(report.entries); + + // Append summary section + const summary = [ + '', + 'SUMMARY', + `Report ID,${report.reportId}`, + `Generated,${report.generatedAt}`, + `Period,${report.periodStart} to ${report.periodEnd}`, + `Total Entries,${report.totals.entryCount}`, + `Total Amount,${report.totals.totalAmount}`, + `Total Expected,${report.totals.totalExpected}`, + `Total Discrepancy,${report.totals.totalDiscrepancy}`, + '', + 'BY PROVIDER', + ...Object.entries(report.totals.byProvider).map( + ([p, v]) => `${p},${v.count},${v.amount}` + ), + '', + 'BY STATUS', + ...Object.entries(report.totals.byStatus).map(([s, c]) => `${s},${c}`), + ].join('\n'); + + await fs.appendFile(outputPath, summary, 'utf8'); + logger.info({ reportId: report.reportId, path: outputPath }, 'CSV report exported'); + return outputPath; +} + +/** + * Export a reconciliation report to JSON. + */ +export async function exportJson( + report: ReconciliationReport, + outputPath: string +): Promise { + await fs.writeFile(outputPath, JSON.stringify(report, null, 2), 'utf8'); + logger.info({ reportId: report.reportId, path: outputPath }, 'JSON report exported'); + return outputPath; +} + +/** + * Generate and export a reconciliation report in the requested format. + */ +export async function exportReport( + report: ReconciliationReport, + format: 'csv' | 'json', + outputDir: string = './exports' +): Promise { + await fs.mkdir(outputDir, { recursive: true }); + const filename = `reconciliation-${report.reportId}.${format}`; + const outputPath = path.join(outputDir, filename); + + if (format === 'csv') { + return exportCsv(report, outputPath); + } + return exportJson(report, outputPath); +}