Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions backend/src/services/reconciliation/export.ts
Original file line number Diff line number Diff line change
@@ -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<string, { count: number; amount: number }>;
byStatus: Record<string, number>;
};
entries: ReconciliationEntry[];
}

/**
* Export a reconciliation report to CSV.
*/
export async function exportCsv(
report: ReconciliationReport,
outputPath: string
): Promise<string> {
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<string> {
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<string> {
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);
}
86 changes: 70 additions & 16 deletions backend/src/services/webhooks/replay.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,85 @@
/** In-memory replay protection; swap for Redis in multi-instance deploys. */
const seenEventIds = new Map<string, number>();
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<string, number>();

/** 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;
}
118 changes: 118 additions & 0 deletions docs/GATEWAY_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -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<PaymentResult> {
// 1. Validate the request
// 2. Call the provider API
// 3. Return a normalized PaymentResult
}

async getPaymentStatus(externalId: string): Promise<PaymentResult> {
// Query the provider for current status
}

async refund(externalId: string, amount?: number): Promise<PaymentResult> {
// 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<WebhookProvider, ProviderVerifier> = {
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
Loading
Loading