From a382d28aa4d4851ea3b48468756a4b94184635d8 Mon Sep 17 00:00:00 2001 From: thewealthyplace Date: Mon, 29 Jun 2026 11:44:40 +0100 Subject: [PATCH] feat: implement four major feature epics (#465, #466, #467, #468) - Backend: Add Payment Reconciliation Report Generator with CSV/PDF Export (#465) - Core: Implement Flash Loan-Protected Liquidity Provider Integration (#466) - Frontend: Add Bulk Payment CSV Upload with Validation Preview (#467) - Backend: Implement Dynamic Fee Calculation Engine with Tiered Pricing (#468) --- backend/prisma/schema.prisma | 279 +++++++++ backend/src/index.ts | 16 + backend/src/routes/bulk-payments.ts | 88 +++ backend/src/routes/fees.ts | 98 ++++ backend/src/routes/liquidity-protection.ts | 82 +++ backend/src/routes/reconciliation.ts | 91 +++ backend/src/services/fees/fee-engine.ts | 327 +++++++++++ backend/src/services/fees/tier-resolver.ts | 61 ++ backend/src/services/fees/volume-discount.ts | 77 +++ .../liquidity/flash-loan-protection.ts | 179 ++++++ .../src/services/liquidity/price-validator.ts | 124 ++++ .../src/services/payments/bulk-processor.ts | 263 +++++++++ .../services/reports/reconciliation-report.ts | 347 +++++++++++ .../dashboard/reports/reconciliation/page.tsx | 360 ++++++++++++ frontend/app/[locale]/payments/bulk/page.tsx | 541 ++++++++++++++++++ 15 files changed, 2933 insertions(+) create mode 100644 backend/src/routes/bulk-payments.ts create mode 100644 backend/src/routes/fees.ts create mode 100644 backend/src/routes/liquidity-protection.ts create mode 100644 backend/src/routes/reconciliation.ts create mode 100644 backend/src/services/fees/fee-engine.ts create mode 100644 backend/src/services/fees/tier-resolver.ts create mode 100644 backend/src/services/fees/volume-discount.ts create mode 100644 backend/src/services/liquidity/flash-loan-protection.ts create mode 100644 backend/src/services/liquidity/price-validator.ts create mode 100644 backend/src/services/payments/bulk-processor.ts create mode 100644 backend/src/services/reports/reconciliation-report.ts create mode 100644 frontend/app/[locale]/dashboard/reports/reconciliation/page.tsx create mode 100644 frontend/app/[locale]/payments/bulk/page.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index aea62a5b..9f658a2c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1694,3 +1694,282 @@ model ReportTemplate { @@index([isPublic, usageCount]) @@map("report_templates") } + +// ─── Issue #465: Payment Reconciliation Report Generator ───────────────────── + +enum ReportJobStatus { + pending + generating + completed + failed + expired +} + +model ReportJob { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String? @map("user_id") + type String @default("reconciliation") // reconciliation, custom + status ReportJobStatus @default(pending) + dateFrom DateTime @map("date_from") + dateTo DateTime @map("date_to") + format String @default("csv") // csv, pdf + filters Json? + progress Int @default(0) + errorMessage String? @map("error_message") + completedAt DateTime? @map("completed_at") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + archives ReportArchive[] + + @@index([tenantId, createdAt]) + @@index([status]) + @@index([tenantId, status]) + @@map("report_jobs") +} + +model ReportArchive { + id String @id @default(uuid()) + reportId String @map("report_id") + format String @default("csv") + fileUrl String @map("file_url") + fileSize Int @default(0) @map("file_size") + rowCount Int @default(0) @map("row_count") + checksum String? @map("checksum") + createdAt DateTime @default(now()) @map("created_at") + + report ReportJob @relation(fields: [reportId], references: [id], onDelete: Cascade) + + @@index([reportId]) + @@map("report_archives") +} + +// ─── Issue #466: Flash Loan-Protected Liquidity Provider Integration ───────── + +enum AnomalySeverity { + low + medium + high + critical +} + +enum CircuitBreakerStatus { + tripped + monitoring + recovered +} + +model PriceAnomalyLog { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + poolId String @map("pool_id") + tokenPair String @map("token_pair") + executionPrice Decimal @map("execution_price") @db.Decimal(30, 18) + referencePrice Decimal @map("reference_price") @db.Decimal(30, 18) + deviationPct Float @map("deviation_pct") + txHash String? @map("tx_hash") + severity AnomalySeverity @default(medium) + detectedAt DateTime @default(now()) @map("detected_at") + metadata Json? + + @@index([tenantId, detectedAt]) + @@index([poolId, detectedAt]) + @@index([severity]) + @@index([detectedAt]) + @@map("price_anomaly_logs") +} + +model CircuitBreakerEvent { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + poolId String @map("pool_id") + eventType String @map("event_type") // price_deviation, repeated_attack, manual + status CircuitBreakerStatus @default(monitoring) + triggeredAt DateTime @default(now()) @map("triggered_at") + recoveredAt DateTime? @map("recovered_at") + anomalyCount Int @default(0) @map("anomaly_count") + thresholdConfig Json? @map("threshold_config") + metadata Json? + + @@index([tenantId, poolId]) + @@index([status]) + @@index([tenantId, status]) + @@index([triggeredAt]) + @@map("circuit_breaker_events") +} + +// ─── Issue #467: Bulk Payment CSV Upload ───────────────────────────────────── + +enum BulkUploadStatus { + pending + validating + processing + completed + partially_completed + failed +} + +model BulkUpload { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String? @map("user_id") + fileName String @map("file_name") + fileSize Int @map("file_size") + mimeType String @map("mime_type") + totalRows Int @default(0) @map("total_rows") + validRows Int @default(0) @map("valid_rows") + errorRows Int @default(0) @map("error_rows") + processedRows Int @default(0) @map("processed_rows") + failedRows Int @default(0) @map("failed_rows") + columnMapping Json? @map("column_mapping") + status BulkUploadStatus @default(pending) + errorReportUrl String? @map("error_report_url") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + rows BulkUploadRow[] + + @@index([tenantId, createdAt]) + @@index([status]) + @@index([tenantId, status]) + @@map("bulk_uploads") +} + +enum BulkUploadRowStatus { + pending + valid + invalid + processing + completed + failed +} + +model BulkUploadRow { + id String @id @default(uuid()) + bulkUploadId String @map("bulk_upload_id") + rowNumber Int @map("row_number") + rawData Json? + parsedData Json? + status BulkUploadRowStatus @default(pending) + errors Json? + warnings Json? + paymentId String? @map("payment_id") + processedAt DateTime? @map("processed_at") + createdAt DateTime @default(now()) @map("created_at") + + bulkUpload BulkUpload @relation(fields: [bulkUploadId], references: [id], onDelete: Cascade) + + @@index([bulkUploadId]) + @@index([status]) + @@index([bulkUploadId, status]) + @@map("bulk_upload_rows") +} + +// ─── Issue #468: Dynamic Fee Calculation Engine with Tiered Pricing ────────── + +enum FeeScheduleStatus { + active + inactive + pending +} + +enum FeeType { + flat + percentage + tiered +} + +model FeeSchedule { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + name String + description String? + feeType FeeType @default(percentage) @map("fee_type") + flatFee Decimal? @map("flat_fee") @db.Decimal(20, 8) + percentageFee Decimal? @map("percentage_fee") @db.Decimal(8, 4) + minFee Decimal? @map("min_fee") @db.Decimal(20, 8) + maxFee Decimal? @map("max_fee") @db.Decimal(20, 8) + gasSurchargePct Decimal? @map("gas_surcharge_pct") @db.Decimal(8, 4) + gasThresholdGwei Float? @map("gas_threshold_gwei") + status FeeScheduleStatus @default(active) + effectiveFrom DateTime @default(now()) @map("effective_from") + effectiveTo DateTime? @map("effective_to") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + volumeTiers VolumeTier[] + merchantOverrides MerchantFeeOverride[] + changeLogs FeeChangeLog[] + + @@index([tenantId, status]) + @@index([tenantId, effectiveFrom, effectiveTo]) + @@map("fee_schedules") +} + +model VolumeTier { + id String @id @default(uuid()) + feeScheduleId String @map("fee_schedule_id") + minVolume Decimal @map("min_volume") @db.Decimal(20, 8) + maxVolume Decimal? @map("max_volume") @db.Decimal(20, 8) + flatFee Decimal? @map("flat_fee") @db.Decimal(20, 8) + percentageFee Decimal @map("percentage_fee") @db.Decimal(8, 4) + createdAt DateTime @default(now()) @map("created_at") + + feeSchedule FeeSchedule @relation(fields: [feeScheduleId], references: [id], onDelete: Cascade) + + @@unique([feeScheduleId, minVolume]) + @@index([feeScheduleId]) + @@map("volume_tiers") +} + +enum OverrideStatus { + active + expired + pending +} + +model MerchantFeeOverride { + id String @id @default(uuid()) + feeScheduleId String @map("fee_schedule_id") + merchantId String @map("merchant_id") + flatFee Decimal? @map("flat_fee") @db.Decimal(20, 8) + percentageFee Decimal? @map("percentage_fee") @db.Decimal(8, 4) + minFee Decimal? @map("min_fee") @db.Decimal(20, 8) + maxFee Decimal? @map("max_fee") @db.Decimal(20, 8) + status OverrideStatus @default(active) + effectiveFrom DateTime @default(now()) @map("effective_from") + effectiveTo DateTime? @map("effective_to") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + feeSchedule FeeSchedule @relation(fields: [feeScheduleId], references: [id], onDelete: Cascade) + + @@unique([feeScheduleId, merchantId]) + @@index([merchantId]) + @@index([feeScheduleId]) + @@index([merchantId, status]) + @@map("merchant_fee_overrides") +} + +model FeeChangeLog { + id String @id @default(uuid()) + feeScheduleId String @map("fee_schedule_id") + field String + oldValue Json? + newValue Json? + changedBy String? @map("changed_by") + reason String? + createdAt DateTime @default(now()) @map("created_at") + + feeSchedule FeeSchedule @relation(fields: [feeScheduleId], references: [id], onDelete: Cascade) + + @@index([feeScheduleId]) + @@index([createdAt]) + @@map("fee_change_logs") +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 142581da..bd27a9dd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -125,6 +125,10 @@ import { swapsRouter } from './routes/swaps.js'; import { treasuryRouter } from './routes/treasury.js'; import { apiKeysRouter } from './routes/api-keys.js'; import { reportsRouter } from './routes/reports.js'; +import { reconciliationRouter } from './routes/reconciliation.js'; +import { liquidityProtectionRouter } from './routes/liquidity-protection.js'; +import { bulkPaymentsRouter } from './routes/bulk-payments.js'; +import { feesRouter } from './routes/fees.js'; import { apiUsageTracker, checkQuota } from './middleware/api-usage-tracker.js'; // Validate environment variables at startup @@ -398,6 +402,18 @@ app.use('/api/v1/treasury', treasuryRouter); // Custom report builder with saved templates — Issue #472 app.use('/api/v1/reports', reportsRouter); +// Payment reconciliation report generator — Issue #465 +app.use('/api/v1/reconciliation', reconciliationRouter); + +// Flash loan-protected liquidity provider integration — Issue #466 +app.use('/api/v1/liquidity/protection', liquidityProtectionRouter); + +// Bulk payment CSV upload — Issue #467 +app.use('/api/v1/payments/bulk', bulkPaymentsRouter); + +// Dynamic fee calculation engine with tiered pricing — Issue #468 +app.use('/api/v1/fees', feesRouter); + // Sandbox environment for testing (with relaxed rate limits) const sandboxRouter = createSandboxRouter(getSandboxManager(), getMockPaymentProcessor(), getTestDataSeeder()); app.use('/api/v1/sandbox', sandboxRateLimiter, sandboxRouter); diff --git a/backend/src/routes/bulk-payments.ts b/backend/src/routes/bulk-payments.ts new file mode 100644 index 00000000..4f6f4d7a --- /dev/null +++ b/backend/src/routes/bulk-payments.ts @@ -0,0 +1,88 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { bulkProcessorService } from '../services/payments/bulk-processor.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export const bulkPaymentsRouter = Router(); + +bulkPaymentsRouter.post('/validate', asyncHandler(async (req, res) => { + const { rows, columnMapping } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!rows || !columnMapping || !tenantId) { + throw new AppError(400, 'rows, columnMapping, and x-tenant-id are required', 'VALIDATION_ERROR'); + } + + const result = await bulkProcessorService.parseAndValidate(rows, columnMapping, tenantId); + res.json(result); +})); + +bulkPaymentsRouter.post('/upload', asyncHandler(async (req, res) => { + const { rows, columnMapping, fileName, fileSize, mimeType } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + const userId = (req as any).user?.id; + + if (!rows || !columnMapping || !fileName || !tenantId) { + throw new AppError(400, 'rows, columnMapping, fileName, and x-tenant-id are required', 'VALIDATION_ERROR'); + } + + const validation = await bulkProcessorService.parseAndValidate(rows, columnMapping, tenantId); + + const bulk = await bulkProcessorService.createBulkUpload( + tenantId, userId, fileName, fileSize || 0, mimeType || 'text/csv', + columnMapping, validation.rows, + ); + + res.json({ + bulkUploadId: bulk.id, + totalRows: validation.rows.length, + validCount: validation.validCount, + errorCount: validation.errorCount, + rows: validation.rows, + }); +})); + +bulkPaymentsRouter.post('/:id/process', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const result = await bulkProcessorService.processBulkUpload(req.params.id, tenantId); + res.json(result); +})); + +bulkPaymentsRouter.get('/:id', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const bulk = await bulkProcessorService.getBulkUpload(req.params.id, tenantId); + res.json(bulk); +})); + +bulkPaymentsRouter.get('/', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const result = await bulkProcessorService.listBulkUploads(tenantId, page, limit); + res.json(result); +})); + +bulkPaymentsRouter.get('/:id/error-report', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const csv = await bulkProcessorService.generateErrorReport(req.params.id, tenantId); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="bulk-errors-${req.params.id}.csv"`); + res.send(csv); +})); + +bulkPaymentsRouter.get('/template', asyncHandler(async (_req, res) => { + const header = 'amount,destination,currency,memo,chain'; + const sample = '100.50,GAX3...ABC,XLM,Payment for services,stellar'; + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename="bulk-payment-template.csv"'); + res.send(`${header}\n${sample}\n`); +})); diff --git a/backend/src/routes/fees.ts b/backend/src/routes/fees.ts new file mode 100644 index 00000000..9a79c98e --- /dev/null +++ b/backend/src/routes/fees.ts @@ -0,0 +1,98 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { feeEngineService } from '../services/fees/fee-engine.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export const feesRouter = Router(); + +feesRouter.post('/calculate', asyncHandler(async (req, res) => { + const { amount, merchantId, currency, chain, gasPriceGwei } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + + if (amount == null || !tenantId) { + throw new AppError(400, 'amount and x-tenant-id are required', 'VALIDATION_ERROR'); + } + + const result = await feeEngineService.calculate({ + tenantId, merchantId, amount, currency, chain, gasPriceGwei, + }); + res.json(result); +})); + +feesRouter.get('/preview', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + const amount = parseFloat(req.query.amount as string); + const merchantId = req.query.merchantId as string; + + if (!amount || !tenantId) { + throw new AppError(400, 'amount query param and x-tenant-id header are required', 'VALIDATION_ERROR'); + } + + const result = await feeEngineService.preview(tenantId, amount, merchantId); + res.json(result); +})); + +feesRouter.get('/schedules', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + const status = req.query.status as string; + + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const schedules = await feeEngineService.listFeeSchedules(tenantId, status); + res.json(schedules); +})); + +feesRouter.post('/schedules', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const schedule = await feeEngineService.createFeeSchedule(tenantId, req.body); + res.status(201).json(schedule); +})); + +feesRouter.get('/schedules/:id', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const schedule = await feeEngineService.getFeeSchedule(req.params.id, tenantId); + res.json(schedule); +})); + +feesRouter.put('/schedules/:id', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const schedule = await feeEngineService.updateFeeSchedule(req.params.id, tenantId, req.body); + res.json(schedule); +})); + +feesRouter.delete('/schedules/:id', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + await feeEngineService.deleteFeeSchedule(req.params.id, tenantId); + res.json({ success: true }); +})); + +feesRouter.post('/schedules/:id/overrides', asyncHandler(async (req, res) => { + const { merchantId, flatFee, percentageFee, minFee, maxFee, effectiveFrom, effectiveTo } = req.body; + + if (!merchantId) { + throw new AppError(400, 'merchantId is required', 'VALIDATION_ERROR'); + } + + const override = await feeEngineService.setMerchantOverride(req.params.id, merchantId, { + flatFee, percentageFee, minFee, maxFee, effectiveFrom, effectiveTo, + }); + res.status(201).json(override); +})); + +feesRouter.post('/schedules/:id/change-log', asyncHandler(async (req, res) => { + const { field, oldValue, newValue, reason } = req.body; + if (!field) throw new AppError(400, 'field is required', 'VALIDATION_ERROR'); + + const log = await feeEngineService.logFeeChange( + req.params.id, field, oldValue, newValue, (req as any).user?.id, reason, + ); + res.status(201).json(log); +})); diff --git a/backend/src/routes/liquidity-protection.ts b/backend/src/routes/liquidity-protection.ts new file mode 100644 index 00000000..b00d32e1 --- /dev/null +++ b/backend/src/routes/liquidity-protection.ts @@ -0,0 +1,82 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { flashLoanProtectionService } from '../services/liquidity/flash-loan-protection.js'; +import { priceValidator } from '../services/liquidity/price-validator.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export const liquidityProtectionRouter = Router(); + +liquidityProtectionRouter.post('/check', asyncHandler(async (req, res) => { + const { poolId, tokenPair, executionPrice, referencePrice, txHash } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!poolId || !tokenPair || executionPrice == null || referencePrice == null) { + throw new AppError(400, 'poolId, tokenPair, executionPrice, and referencePrice are required', 'VALIDATION_ERROR'); + } + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const result = await flashLoanProtectionService.checkTransaction( + tenantId, poolId, tokenPair, executionPrice, referencePrice, txHash, + ); + res.json(result); +})); + +liquidityProtectionRouter.get('/status/:poolId', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const status = await flashLoanProtectionService.getPoolProtectionStatus( + req.params.poolId, tenantId, + ); + res.json(status); +})); + +liquidityProtectionRouter.get('/anomalies/:poolId', asyncHandler(async (req, res) => { + const { poolId } = req.params; + const minutes = parseInt(req.query.minutes as string) || 60; + const minSeverity = req.query.severity as string; + + const anomalies = await priceValidator.getRecentAnomalies(poolId, minutes, minSeverity); + res.json({ anomalies, count: anomalies.length }); +})); + +liquidityProtectionRouter.get('/metrics/:poolId', asyncHandler(async (req, res) => { + const hours = parseInt(req.query.hours as string) || 24; + const metrics = await priceValidator.getPoolPriceMetrics(req.params.poolId, hours); + res.json(metrics || { message: 'No data for this period' }); +})); + +liquidityProtectionRouter.get('/circuit-breaker/:poolId', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const status = await flashLoanProtectionService.getCircuitBreakerStatus( + req.params.poolId, tenantId, + ); + res.json(status); +})); + +liquidityProtectionRouter.post('/circuit-breaker/trigger', asyncHandler(async (req, res) => { + const { poolId, eventType, reason } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!poolId || !eventType || !tenantId) { + throw new AppError(400, 'poolId, eventType, and x-tenant-id are required', 'VALIDATION_ERROR'); + } + + const event = await flashLoanProtectionService.triggerCircuitBreaker( + tenantId, poolId, eventType, reason || 'Manual trigger', + ); + res.json(event); +})); + +liquidityProtectionRouter.post('/circuit-breaker/recover/:eventId', asyncHandler(async (req, res) => { + const event = await flashLoanProtectionService.recoverCircuitBreaker(req.params.eventId); + res.json(event); +})); + +liquidityProtectionRouter.get('/twap/:poolId', asyncHandler(async (req, res) => { + const lookback = parseInt(req.query.lookbackMinutes as string) || 15; + const twapPrice = await flashLoanProtectionService.getTWAPPrice(req.params.poolId, lookback); + res.json({ poolId: req.params.poolId, twapPrice, lookbackMinutes: lookback }); +})); diff --git a/backend/src/routes/reconciliation.ts b/backend/src/routes/reconciliation.ts new file mode 100644 index 00000000..4f10c031 --- /dev/null +++ b/backend/src/routes/reconciliation.ts @@ -0,0 +1,91 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { reconciliationReportService } from '../services/reports/reconciliation-report.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export const reconciliationRouter = Router(); + +reconciliationRouter.post('/generate', asyncHandler(async (req, res) => { + const { dateFrom, dateTo, chain, status, format } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + const userId = (req as any).user?.id; + + if (!dateFrom || !dateTo) { + throw new AppError(400, 'dateFrom and dateTo are required', 'VALIDATION_ERROR'); + } + if (!tenantId) { + throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + } + + const result = await reconciliationReportService.generateReport({ + dateFrom, dateTo, tenantId, chain, status, format: format || 'csv', + }); + + const job = await reconciliationReportService.saveReportJob( + tenantId, userId, dateFrom, dateTo, format || 'csv', + { chain, status }, + ); + + if (format === 'pdf') { + await reconciliationReportService.createArchive(job.id, 'pdf', `reports/reconciliation-${job.id}.pdf`, result.report.rows.length, result.pdf.length); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="reconciliation-${dateFrom}-${dateTo}.pdf"`); + return res.send(result.pdf); + } + + await reconciliationReportService.createArchive(job.id, 'csv', `reports/reconciliation-${job.id}.csv`, result.report.rows.length, result.csv.length); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="reconciliation-${dateFrom}-${dateTo}.csv"`); + res.send(result.csv); +})); + +reconciliationRouter.post('/preview', asyncHandler(async (req, res) => { + const { dateFrom, dateTo, chain, status } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!dateFrom || !dateTo || !tenantId) { + throw new AppError(400, 'dateFrom, dateTo, and x-tenant-id are required', 'VALIDATION_ERROR'); + } + + const result = await reconciliationReportService.generateReport({ + dateFrom, dateTo, tenantId, chain, status, + }); + + res.json({ + rows: result.report.rows.slice(0, 100), + totalRows: result.report.rows.length, + dailySummaries: result.report.dailySummaries, + weeklySummaries: result.report.weeklySummaries, + monthlySummaries: result.report.monthlySummaries, + dateFrom: result.report.dateFrom, + dateTo: result.report.dateTo, + generatedAt: result.report.generatedAt, + }); +})); + +reconciliationRouter.get('/jobs', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const result = await reconciliationReportService.listJobs(tenantId, page, limit); + res.json(result); +})); + +reconciliationRouter.get('/jobs/:id', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const job = await reconciliationReportService.getJob(req.params.id, tenantId); + res.json(job); +})); + +reconciliationRouter.delete('/jobs/:id', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + await reconciliationReportService.deleteJob(req.params.id, tenantId); + res.json({ success: true }); +})); diff --git a/backend/src/services/fees/fee-engine.ts b/backend/src/services/fees/fee-engine.ts new file mode 100644 index 00000000..04606a96 --- /dev/null +++ b/backend/src/services/fees/fee-engine.ts @@ -0,0 +1,327 @@ +import { prisma } from '../../lib/prisma.js'; +import { tierResolver } from './tier-resolver.js'; +import { volumeDiscountService } from './volume-discount.js'; +import { AppError } from '../../middleware/errorHandler.js'; + +interface FeeBreakdown { + baseFee: number; + processingFee: number; + gasSurcharge: number; + volumeDiscount: number; + totalFee: number; + netAmount: number; +} + +interface FeeCalculationInput { + tenantId: string; + merchantId?: string; + amount: number; + currency?: string; + chain?: string; + gasPriceGwei?: number; +} + +interface FeeScheduleInfo { + id: string; + name: string; + feeType: string; + flatFee: number | null; + percentageFee: number | null; + minFee: number | null; + maxFee: number | null; + gasSurchargePct: number | null; + gasThresholdGwei: number | null; + effectiveFrom: string; + effectiveTo: string | null; + status: string; +} + +export class FeeEngineService { + private readonly DEFAULT_GAS_THRESHOLD_GWEI = 50; + private readonly DEFAULT_GAS_SURCHARGE_PCT = 10; + private readonly CACHE_TTL_SECONDS = 300; // 5 min + + async calculate(input: FeeCalculationInput): Promise<{ + breakdown: FeeBreakdown; + schedule: FeeScheduleInfo; + appliedVolumeDiscount: boolean; + }> { + const schedule = await this.resolveFeeSchedule(input.tenantId); + if (!schedule) throw new AppError(404, 'No active fee schedule found', 'FEE_SCHEDULE_NOT_FOUND'); + + const tier = await tierResolver.resolveTier(schedule.id, input.amount); + if (!tier) throw new AppError(400, 'No applicable fee tier for this amount', 'NO_TIER_FOUND'); + + let baseFee = tier.flatFee + (input.amount * tier.percentageFee / 100); + baseFee = this.applyMinMax(baseFee, tier.minFee, tier.maxFee); + + const processingFee = baseFee; + let volumeDiscount = 0; + let appliedVolumeDiscount = false; + + if (input.merchantId) { + const override = await this.getMerchantOverride(schedule.id, input.merchantId); + if (override) { + const overrideFlat = Number(override.flatFee ?? tier.flatFee); + const overridePct = Number(override.percentageFee ?? tier.percentageFee); + baseFee = overrideFlat + (input.amount * overridePct / 100); + baseFee = this.applyMinMax(baseFee, Number(override.minFee ?? tier.minFee), Number(override.maxFee ?? tier.maxFee)); + } + + const discountResult = await volumeDiscountService.calculateDiscount( + input.tenantId, input.merchantId, schedule.id, input.amount, + ); + if (discountResult.discountPct > 0) { + volumeDiscount = baseFee * (discountResult.discountPct / 100); + baseFee -= volumeDiscount; + appliedVolumeDiscount = true; + } + } + + let gasSurcharge = 0; + const gasThreshold = Number(schedule.gasThresholdGwei ?? this.DEFAULT_GAS_THRESHOLD_GWEI); + const inputGasPct = Number(schedule.gasSurchargePct ?? this.DEFAULT_GAS_SURCHARGE_PCT); + + if (input.gasPriceGwei && input.gasPriceGwei > gasThreshold) { + gasSurcharge = baseFee * (inputGasPct / 100); + baseFee += gasSurcharge; + } + + const totalFee = processingFee + gasSurcharge; + const netAmount = input.amount - totalFee; + + const scheduleInfo: FeeScheduleInfo = { + id: schedule.id, + name: schedule.name, + feeType: schedule.feeType, + flatFee: schedule.flatFee ? Number(schedule.flatFee) : null, + percentageFee: schedule.percentageFee ? Number(schedule.percentageFee) : null, + minFee: schedule.minFee ? Number(schedule.minFee) : null, + maxFee: schedule.maxFee ? Number(schedule.maxFee) : null, + gasSurchargePct: schedule.gasSurchargePct ? Number(schedule.gasSurchargePct) : null, + gasThresholdGwei: schedule.gasThresholdGwei ? Number(schedule.gasThresholdGwei) : null, + effectiveFrom: schedule.effectiveFrom.toISOString(), + effectiveTo: schedule.effectiveTo?.toISOString() ?? null, + status: schedule.status, + }; + + return { + breakdown: { + baseFee: parseFloat(baseFee.toFixed(8)), + processingFee: parseFloat(processingFee.toFixed(8)), + gasSurcharge: parseFloat(gasSurcharge.toFixed(8)), + volumeDiscount: parseFloat(volumeDiscount.toFixed(8)), + totalFee: parseFloat(totalFee.toFixed(8)), + netAmount: parseFloat(netAmount.toFixed(8)), + }, + schedule: scheduleInfo, + appliedVolumeDiscount, + }; + } + + async preview(tenantId: string, amount: number, merchantId?: string) { + return this.calculate({ tenantId, amount, merchantId }); + } + + async listFeeSchedules(tenantId: string, status?: string) { + const where: any = { tenantId, deletedAt: null }; + if (status) where.status = status; + return prisma.feeSchedule.findMany({ + where, + include: { + volumeTiers: { orderBy: { minVolume: 'asc' } }, + merchantOverrides: { where: { status: 'active' } }, + }, + orderBy: { effectiveFrom: 'desc' }, + }); + } + + async createFeeSchedule(tenantId: string, data: any) { + const { volumeTiers, ...scheduleData } = data; + + const schedule = await prisma.feeSchedule.create({ + data: { + ...scheduleData, + tenantId, + volumeTiers: volumeTiers ? { + create: volumeTiers.map((t: any) => ({ + minVolume: t.minVolume, + maxVolume: t.maxVolume ?? null, + flatFee: t.flatFee ?? null, + percentageFee: t.percentageFee, + })), + } : undefined, + }, + include: { volumeTiers: true }, + }); + + await prisma.feeChangeLog.create({ + data: { + feeScheduleId: schedule.id, + field: 'create', + newValue: { name: schedule.name, feeType: schedule.feeType }, + }, + }); + + return schedule; + } + + async updateFeeSchedule(scheduleId: string, tenantId: string, data: any) { + const existing = await prisma.feeSchedule.findFirst({ + where: { id: scheduleId, tenantId, deletedAt: null }, + }); + if (!existing) throw new AppError(404, 'Fee schedule not found', 'NOT_FOUND'); + + const { volumeTiers, ...scheduleData } = data; + + const changes: { field: string; oldValue: any; newValue: any }[] = []; + for (const [key, value] of Object.entries(scheduleData)) { + if (JSON.stringify((existing as any)[key]) !== JSON.stringify(value)) { + changes.push({ field: key, oldValue: (existing as any)[key], newValue: value }); + } + } + + const schedule = await prisma.feeSchedule.update({ + where: { id: scheduleId }, + data: { + ...scheduleData, + volumeTiers: volumeTiers ? { + deleteMany: {}, + create: volumeTiers.map((t: any) => ({ + minVolume: t.minVolume, + maxVolume: t.maxVolume ?? null, + flatFee: t.flatFee ?? null, + percentageFee: t.percentageFee, + })), + } : undefined, + }, + include: { volumeTiers: true }, + }); + + for (const change of changes) { + await prisma.feeChangeLog.create({ + data: { + feeScheduleId: scheduleId, + field: change.field, + oldValue: change.oldValue, + newValue: change.newValue, + }, + }); + } + + return schedule; + } + + async getFeeSchedule(scheduleId: string, tenantId: string) { + const schedule = await prisma.feeSchedule.findFirst({ + where: { id: scheduleId, tenantId, deletedAt: null }, + include: { + volumeTiers: { orderBy: { minVolume: 'asc' } }, + merchantOverrides: { where: { status: 'active' } }, + changeLogs: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, + }); + if (!schedule) throw new AppError(404, 'Fee schedule not found', 'NOT_FOUND'); + return schedule; + } + + async deleteFeeSchedule(scheduleId: string, tenantId: string) { + const existing = await prisma.feeSchedule.findFirst({ + where: { id: scheduleId, tenantId, deletedAt: null }, + }); + if (!existing) throw new AppError(404, 'Fee schedule not found', 'NOT_FOUND'); + return prisma.feeSchedule.update({ + where: { id: scheduleId }, + data: { deletedAt: new Date(), status: 'inactive' }, + }); + } + + async setMerchantOverride( + feeScheduleId: string, + merchantId: string, + data: { + flatFee?: number; + percentageFee?: number; + minFee?: number; + maxFee?: number; + effectiveFrom?: string; + effectiveTo?: string; + }, + ) { + const existing = await prisma.merchantFeeOverride.findUnique({ + where: { feeScheduleId_merchantId: { feeScheduleId, merchantId } }, + }); + + if (existing) { + return prisma.merchantFeeOverride.update({ + where: { id: existing.id }, + data: { + ...data, + effectiveFrom: data.effectiveFrom ? new Date(data.effectiveFrom) : undefined, + effectiveTo: data.effectiveTo ? new Date(data.effectiveTo) : undefined, + }, + }); + } + + return prisma.merchantFeeOverride.create({ + data: { + feeScheduleId, + merchantId, + flatFee: data.flatFee, + percentageFee: data.percentageFee, + minFee: data.minFee, + maxFee: data.maxFee, + effectiveFrom: data.effectiveFrom ? new Date(data.effectiveFrom) : new Date(), + effectiveTo: data.effectiveTo ? new Date(data.effectiveTo) : null, + }, + }); + } + + async getMerchantOverride(feeScheduleId: string, merchantId: string) { + return prisma.merchantFeeOverride.findFirst({ + where: { + feeScheduleId, + merchantId, + status: 'active', + effectiveFrom: { lte: new Date() }, + OR: [ + { effectiveTo: null }, + { effectiveTo: { gte: new Date() } }, + ], + }, + }); + } + + async logFeeChange(feeScheduleId: string, field: string, oldValue: any, newValue: any, changedBy?: string, reason?: string) { + return prisma.feeChangeLog.create({ + data: { feeScheduleId, field, oldValue, newValue, changedBy, reason }, + }); + } + + private async resolveFeeSchedule(tenantId: string) { + const now = new Date(); + return prisma.feeSchedule.findFirst({ + where: { + tenantId, + status: 'active', + effectiveFrom: { lte: now }, + OR: [ + { effectiveTo: null }, + { effectiveTo: { gte: now } }, + ], + deletedAt: null, + }, + include: { volumeTiers: { orderBy: { minVolume: 'asc' } } }, + orderBy: { effectiveFrom: 'desc' }, + }); + } + + private applyMinMax(fee: number, min: number | null, max: number | null): number { + let result = fee; + if (min != null && result < min) result = min; + if (max != null && result > max) result = max; + return result; + } +} + +export const feeEngineService = new FeeEngineService(); diff --git a/backend/src/services/fees/tier-resolver.ts b/backend/src/services/fees/tier-resolver.ts new file mode 100644 index 00000000..5b97ca16 --- /dev/null +++ b/backend/src/services/fees/tier-resolver.ts @@ -0,0 +1,61 @@ +import { prisma } from '../../lib/prisma.js'; + +interface TierResult { + tierLabel: string; + flatFee: number; + percentageFee: number; + minFee: number | null; + maxFee: number | null; +} + +export class TierResolver { + async resolveTier(feeScheduleId: string, amount: number): Promise { + const schedule = await prisma.feeSchedule.findUnique({ + where: { id: feeScheduleId }, + include: { volumeTiers: { orderBy: { minVolume: 'asc' } } }, + }); + + if (!schedule || schedule.status !== 'active') return null; + + if (schedule.feeType !== 'tiered' || schedule.volumeTiers.length === 0) { + return { + tierLabel: 'default', + flatFee: Number(schedule.flatFee ?? 0), + percentageFee: Number(schedule.percentageFee ?? 0), + minFee: schedule.minFee ? Number(schedule.minFee) : null, + maxFee: schedule.maxFee ? Number(schedule.maxFee) : null, + }; + } + + const matchedTier = schedule.volumeTiers.find(tier => { + const minVol = Number(tier.minVolume); + const maxVol = Number(tier.maxVolume ?? Infinity); + return amount >= minVol && amount <= maxVol; + }); + + if (matchedTier) { + return { + tierLabel: `${matchedTier.minVolume}-${matchedTier.maxVolume ?? '∞'}`, + flatFee: Number(matchedTier.flatFee ?? 0), + percentageFee: Number(matchedTier.percentageFee), + minFee: null, + maxFee: null, + }; + } + + const lastTier = schedule.volumeTiers[schedule.volumeTiers.length - 1]; + if (lastTier && amount >= Number(lastTier.maxVolume ?? Infinity)) { + return { + tierLabel: `>${lastTier.maxVolume ?? lastTier.minVolume}`, + flatFee: Number(lastTier.flatFee ?? 0), + percentageFee: Number(lastTier.percentageFee), + minFee: null, + maxFee: null, + }; + } + + return null; + } +} + +export const tierResolver = new TierResolver(); diff --git a/backend/src/services/fees/volume-discount.ts b/backend/src/services/fees/volume-discount.ts new file mode 100644 index 00000000..491d17d7 --- /dev/null +++ b/backend/src/services/fees/volume-discount.ts @@ -0,0 +1,77 @@ +import { prisma } from '../../lib/prisma.js'; + +interface VolumeDiscountResult { + originalFee: number; + discountedFee: number; + discountPct: number; + monthlyVolume: number; + volumeTier: string; +} + +export class VolumeDiscountService { + async calculateDiscount( + tenantId: string, + merchantId: string, + feeScheduleId: string, + amount: number, + ): Promise { + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const monthlyPayments = await prisma.payment.findMany({ + where: { + tenantId, + userId: merchantId, + createdAt: { gte: startOfMonth }, + status: { in: ['completed', 'processing'] }, + deletedAt: null, + }, + }); + + const monthlyVolume = monthlyPayments.reduce((sum, p) => sum + Number(p.amount), 0) + amount; + + const schedule = await prisma.feeSchedule.findUnique({ + where: { id: feeScheduleId }, + include: { volumeTiers: { orderBy: { minVolume: 'asc' } } }, + }); + + if (!schedule || schedule.volumeTiers.length === 0) { + return { + originalFee: amount, + discountedFee: amount, + discountPct: 0, + monthlyVolume, + volumeTier: 'no-tiers', + }; + } + + const matchedTier = [...schedule.volumeTiers].reverse().find(tier => { + const minVol = Number(tier.minVolume); + return monthlyVolume >= minVol; + }); + + if (matchedTier) { + const discountPct = 100 - Number(matchedTier.percentageFee); + const originalFee = amount; + const discountedFee = amount * (Number(matchedTier.percentageFee) / 100); + + return { + originalFee, + discountedFee, + discountPct, + monthlyVolume, + volumeTier: `tier-${matchedTier.minVolume}`, + }; + } + + return { + originalFee: amount, + discountedFee: amount, + discountPct: 0, + monthlyVolume, + volumeTier: 'base', + }; + } +} + +export const volumeDiscountService = new VolumeDiscountService(); diff --git a/backend/src/services/liquidity/flash-loan-protection.ts b/backend/src/services/liquidity/flash-loan-protection.ts new file mode 100644 index 00000000..e0d75e9c --- /dev/null +++ b/backend/src/services/liquidity/flash-loan-protection.ts @@ -0,0 +1,179 @@ +import { prisma } from '../../lib/prisma.js'; +import { priceValidator } from './price-validator.js'; +import { AppError } from '../../middleware/errorHandler.js'; + +interface FlashLoanCheckResult { + passed: boolean; + priceCheck: { + executionPrice: number; + referencePrice: number; + deviationPct: number; + }; + circuitBreakerStatus: string; + twapPrice?: number; + reason?: string; +} + +export class FlashLoanProtectionService { + private readonly DEFAULT_TWAP_LOOKBACK_MINUTES = 15; + private readonly MAX_ANOMALIES_BEFORE_CIRCUIT_BREAKER = 5; + private readonly CIRCUIT_BREAKER_COOLDOWN_MINUTES = 30; + + async checkTransaction( + tenantId: string, + poolId: string, + tokenPair: string, + executionPrice: number, + referencePrice: number, + txHash?: string, + ): Promise { + const circuitBreaker = await this.getCircuitBreakerStatus(poolId, tenantId); + if (circuitBreaker.status === 'tripped') { + return { + passed: false, + priceCheck: { executionPrice, referencePrice, deviationPct: 0 }, + circuitBreakerStatus: 'tripped', + reason: `Circuit breaker is active for pool ${poolId}. Transaction rejected.`, + }; + } + + const priceResult = await priceValidator.validatePrice( + tenantId, poolId, tokenPair, executionPrice, referencePrice, txHash, + ); + + if (!priceResult.isValid) { + const recentAnomalies = await priceValidator.getRecentAnomalies(poolId, 60, 'medium'); + const totalAnomalies = recentAnomalies.length; + + if (totalAnomalies >= this.MAX_ANOMALIES_BEFORE_CIRCUIT_BREAKER) { + await this.triggerCircuitBreaker( + tenantId, poolId, 'price_deviation', + `Repeated price anomalies detected: ${totalAnomalies} in last 60 minutes`, + ); + return { + passed: false, + priceCheck: { + executionPrice: priceResult.executionPrice, + referencePrice: priceResult.referencePrice, + deviationPct: priceResult.deviationPct, + }, + circuitBreakerStatus: 'tripped', + reason: `Price deviation ${priceResult.deviationPct.toFixed(2)}% exceeds threshold. Circuit breaker engaged.`, + }; + } + + return { + passed: false, + priceCheck: { + executionPrice: priceResult.executionPrice, + referencePrice: priceResult.referencePrice, + deviationPct: priceResult.deviationPct, + }, + circuitBreakerStatus: 'monitoring', + reason: priceResult.message, + }; + } + + return { + passed: true, + priceCheck: { + executionPrice: priceResult.executionPrice, + referencePrice: priceResult.referencePrice, + deviationPct: priceResult.deviationPct, + }, + circuitBreakerStatus: 'monitoring', + }; + } + + async getTWAPPrice( + poolId: string, + lookbackMinutes?: number, + ): Promise { + const minutes = lookbackMinutes ?? this.DEFAULT_TWAP_LOOKBACK_MINUTES; + const since = new Date(Date.now() - minutes * 60 * 1000); + + const anomalies = await prisma.priceAnomalyLog.findMany({ + where: { + poolId, + detectedAt: { gte: since }, + }, + orderBy: { detectedAt: 'asc' }, + }); + + if (anomalies.length === 0) return null; + + const validPrices = anomalies.filter(a => a.referencePrice.gt(0)); + if (validPrices.length === 0) return null; + + const sum = validPrices.reduce((s, a) => s + Number(a.referencePrice), 0); + return sum / validPrices.length; + } + + async getCircuitBreakerStatus(poolId: string, tenantId: string) { + const activeBreaker = await prisma.circuitBreakerEvent.findFirst({ + where: { + poolId, + tenantId, + status: 'tripped', + }, + orderBy: { triggeredAt: 'desc' }, + }); + + if (!activeBreaker) return { status: 'monitoring', event: null }; + + const cooldownEnd = new Date(activeBreaker.triggeredAt.getTime() + this.CIRCUIT_BREAKER_COOLDOWN_MINUTES * 60 * 1000); + if (new Date() > cooldownEnd) { + await this.recoverCircuitBreaker(activeBreaker.id); + return { status: 'recovered', event: null }; + } + + return { status: 'tripped', event: activeBreaker }; + } + + async triggerCircuitBreaker( + tenantId: string, + poolId: string, + eventType: string, + reason: string, + ) { + const recentAnomalies = await priceValidator.getRecentAnomalies(poolId, 60); + + return prisma.circuitBreakerEvent.create({ + data: { + tenantId, + poolId, + eventType, + status: 'tripped', + anomalyCount: recentAnomalies.length, + metadata: { reason, triggeredAt: new Date().toISOString() }, + }, + }); + } + + async recoverCircuitBreaker(eventId: string) { + return prisma.circuitBreakerEvent.update({ + where: { id: eventId }, + data: { + status: 'recovered', + recoveredAt: new Date(), + }, + }); + } + + async getPoolProtectionStatus(poolId: string, tenantId: string) { + const breaker = await this.getCircuitBreakerStatus(poolId, tenantId); + const metrics = await priceValidator.getPoolPriceMetrics(poolId, 24); + const recentAnomalies = await priceValidator.getRecentAnomalies(poolId, 60, 'medium'); + + return { + poolId, + circuitBreaker: breaker.status, + circuitBreakerEvent: breaker.event, + metrics, + recentAnomalyCount: recentAnomalies.length, + recentAnomalies: recentAnomalies.slice(0, 10), + }; + } +} + +export const flashLoanProtectionService = new FlashLoanProtectionService(); diff --git a/backend/src/services/liquidity/price-validator.ts b/backend/src/services/liquidity/price-validator.ts new file mode 100644 index 00000000..345eea06 --- /dev/null +++ b/backend/src/services/liquidity/price-validator.ts @@ -0,0 +1,124 @@ +import { prisma } from '../../lib/prisma.js'; + +interface PriceValidationResult { + isValid: boolean; + executionPrice: number; + referencePrice: number; + deviationPct: number; + severity: 'low' | 'medium' | 'high' | 'critical'; + message: string; +} + +export class PriceValidator { + private readonly DEFAULT_DEVIATION_THRESHOLD = 5; // 5% + private readonly WARNING_THRESHOLD = 3; // 3% + + async validatePrice( + tenantId: string, + poolId: string, + tokenPair: string, + executionPrice: number, + referencePrice: number, + txHash?: string, + customThreshold?: number, + ): Promise { + if (referencePrice <= 0) { + return { + isValid: false, + executionPrice, + referencePrice, + deviationPct: 100, + severity: 'critical', + message: 'Reference price is zero or negative, cannot validate', + }; + } + + const deviationPct = Math.abs(((executionPrice - referencePrice) / referencePrice) * 100); + const threshold = customThreshold ?? this.DEFAULT_DEVIATION_THRESHOLD; + + let severity: PriceValidationResult['severity'] = 'low'; + let isValid = true; + let message = `Price deviation ${deviationPct.toFixed(2)}% within threshold ${threshold}%`; + + if (deviationPct > threshold) { + isValid = false; + severity = deviationPct > 15 ? 'critical' : deviationPct > 10 ? 'high' : 'medium'; + message = `Price deviation ${deviationPct.toFixed(2)}% exceeds threshold ${threshold}%`; + } else if (deviationPct > this.WARNING_THRESHOLD) { + severity = 'low'; + message = `Price deviation ${deviationPct.toFixed(2)}% exceeds warning threshold ${this.WARNING_THRESHOLD}%`; + } + + await prisma.priceAnomalyLog.create({ + data: { + tenantId, + poolId, + tokenPair, + executionPrice, + referencePrice, + deviationPct, + txHash, + severity, + metadata: { + threshold, + warningThreshold: this.WARNING_THRESHOLD, + isValid, + message, + }, + }, + }); + + return { isValid, executionPrice, referencePrice, deviationPct, severity, message }; + } + + async getRecentAnomalies( + poolId: string, + minutes: number = 60, + minSeverity?: string, + ) { + const since = new Date(Date.now() - minutes * 60 * 1000); + const where: any = { + poolId, + detectedAt: { gte: since }, + }; + if (minSeverity) { + where.severity = { in: ['medium', 'high', 'critical'] }; + } + return prisma.priceAnomalyLog.findMany({ + where, + orderBy: { detectedAt: 'desc' }, + }); + } + + async getPoolPriceMetrics(poolId: string, hours: number = 24) { + const since = new Date(Date.now() - hours * 60 * 60 * 1000); + const anomalies = await prisma.priceAnomalyLog.findMany({ + where: { + poolId, + detectedAt: { gte: since }, + }, + orderBy: { detectedAt: 'asc' }, + }); + + if (anomalies.length === 0) return null; + + const totalDeviations = anomalies.length; + const criticalCount = anomalies.filter(a => a.severity === 'critical').length; + const highCount = anomalies.filter(a => a.severity === 'high').length; + const avgDeviation = anomalies.reduce((s, a) => s + a.deviationPct, 0) / anomalies.length; + const maxDeviation = Math.max(...anomalies.map(a => a.deviationPct)); + + return { + poolId, + periodHours: hours, + totalAnomalies: totalDeviations, + criticalCount, + highCount, + avgDeviationPct: parseFloat(avgDeviation.toFixed(2)), + maxDeviationPct: parseFloat(maxDeviation.toFixed(2)), + lastAnomaly: anomalies[anomalies.length - 1], + }; + } +} + +export const priceValidator = new PriceValidator(); diff --git a/backend/src/services/payments/bulk-processor.ts b/backend/src/services/payments/bulk-processor.ts new file mode 100644 index 00000000..6bd2811f --- /dev/null +++ b/backend/src/services/payments/bulk-processor.ts @@ -0,0 +1,263 @@ +import { prisma } from '../../lib/prisma.js'; +import { AppError } from '../../middleware/errorHandler.js'; + +interface ParsedRow { + amount: number; + destination: string; + currency?: string; + memo?: string; + chain?: string; +} + +interface ColumnMapping { + amount: string; + destination: string; + currency?: string; + memo?: string; + chain?: string; +} + +interface ValidationResult { + rowNumber: number; + status: 'valid' | 'invalid'; + errors: string[]; + warnings: string[]; + parsed?: ParsedRow; +} + +export class BulkProcessorService { + async parseAndValidate( + rawData: any[], + columnMapping: ColumnMapping, + tenantId: string, + ): Promise<{ rows: ValidationResult[]; validCount: number; errorCount: number }> { + const results: ValidationResult[] = []; + let validCount = 0; + let errorCount = 0; + + for (let i = 0; i < rawData.length; i++) { + const row = rawData[i]; + const rowNum = i + 1; + const errors: string[] = []; + const warnings: string[] = []; + + const amountRaw = row[columnMapping.amount]; + const amount = parseFloat(amountRaw); + if (!amountRaw || isNaN(amount) || amount <= 0) { + errors.push('Invalid or missing amount'); + } else if (amount > 100000000) { + warnings.push('Amount exceeds 100M, verify correctness'); + } + + const destination = String(row[columnMapping.destination] || '').trim(); + if (!destination) { + errors.push('Missing destination address'); + } else if (destination.length < 10) { + errors.push('Destination address appears too short'); + } else if (destination.length > 100) { + errors.push('Destination address exceeds maximum length'); + } + + const currency = columnMapping.currency + ? String(row[columnMapping.currency] || '').trim().toUpperCase() || 'XLM' + : 'XLM'; + const memo = columnMapping.memo + ? String(row[columnMapping.memo] || '').trim() + : undefined; + const chain = columnMapping.chain + ? String(row[columnMapping.chain] || '').trim().toLowerCase() || 'stellar' + : 'stellar'; + + const parsed: ParsedRow = { amount, destination, currency, memo, chain }; + + results.push({ + rowNumber: rowNum, + status: errors.length > 0 ? 'invalid' : 'valid', + errors, + warnings, + parsed: errors.length === 0 ? parsed : undefined, + }); + + if (errors.length > 0) errorCount++; + else validCount++; + } + + return { rows: results, validCount, errorCount }; + } + + async createBulkUpload( + tenantId: string, + userId: string | undefined, + fileName: string, + fileSize: number, + mimeType: string, + columnMapping: ColumnMapping, + validationResults: ValidationResult[], + ) { + const validRows = validationResults.filter(r => r.status === 'valid'); + const errorRows = validationResults.filter(r => r.status === 'invalid'); + + const bulk = await prisma.bulkUpload.create({ + data: { + tenantId, + userId, + fileName, + fileSize, + mimeType, + totalRows: validationResults.length, + validRows: validRows.length, + errorRows: errorRows.length, + columnMapping, + status: 'pending', + }, + }); + + await prisma.bulkUploadRow.createMany({ + data: validationResults.map(r => ({ + bulkUploadId: bulk.id, + rowNumber: r.rowNumber, + rawData: r.parsed ? { amount: r.parsed.amount, destination: r.parsed.destination, currency: r.parsed.currency, memo: r.parsed.memo, chain: r.parsed.chain } : null, + parsedData: r.parsed || null, + status: r.status === 'valid' ? 'valid' : 'invalid', + errors: r.errors.length > 0 ? r.errors : undefined, + warnings: r.warnings.length > 0 ? r.warnings : undefined, + })), + }); + + return bulk; + } + + async processBulkUpload(bulkUploadId: string, tenantId: string) { + const bulk = await prisma.bulkUpload.findFirst({ + where: { id: bulkUploadId, tenantId, deletedAt: null }, + include: { rows: { where: { status: 'valid' }, orderBy: { rowNumber: 'asc' } } }, + }); + + if (!bulk) throw new AppError(404, 'Bulk upload not found', 'NOT_FOUND'); + + await prisma.bulkUpload.update({ + where: { id: bulkUploadId }, + data: { status: 'processing' }, + }); + + let processedCount = 0; + let failedCount = 0; + const batchSize = 50; + const validRows = bulk.rows; + const totalBatches = Math.ceil(validRows.length / batchSize); + + for (let batch = 0; batch < totalBatches; batch++) { + const batchRows = validRows.slice(batch * batchSize, (batch + 1) * batchSize); + + await Promise.allSettled( + batchRows.map(async (row) => { + try { + const data = row.parsedData as any; + if (!data) return; + + const payment = await prisma.payment.create({ + data: { + tenantId, + amount: data.amount, + currency: data.currency || 'XLM', + network: data.chain || 'stellar', + status: 'pending', + toAddress: data.destination, + memo: data.memo, + metadata: { bulkUploadId, sourceRow: row.rowNumber }, + }, + }); + + await prisma.bulkUploadRow.update({ + where: { id: row.id }, + data: { + status: 'completed', + paymentId: payment.id, + processedAt: new Date(), + }, + }); + processedCount++; + } catch (err: any) { + await prisma.bulkUploadRow.update({ + where: { id: row.id }, + data: { + status: 'failed', + errors: [err.message || 'Processing failed'], + }, + }); + failedCount++; + } + }), + ); + } + + const finalStatus = + failedCount === 0 ? 'completed' + : processedCount > 0 ? 'partially_completed' + : 'failed'; + + await prisma.bulkUpload.update({ + where: { id: bulkUploadId }, + data: { + status: finalStatus, + processedRows: processedCount, + failedRows: failedCount, + completedAt: new Date(), + }, + }); + + return { processedCount, failedCount, totalBatches, status: finalStatus }; + } + + async getBulkUpload(bulkUploadId: string, tenantId: string) { + const bulk = await prisma.bulkUpload.findFirst({ + where: { id: bulkUploadId, tenantId, deletedAt: null }, + include: { rows: { orderBy: { rowNumber: 'asc' } } }, + }); + if (!bulk) throw new AppError(404, 'Bulk upload not found', 'NOT_FOUND'); + return bulk; + } + + async listBulkUploads(tenantId: string, page: number = 1, limit: number = 20) { + const skip = (page - 1) * limit; + const [items, total] = await Promise.all([ + prisma.bulkUpload.findMany({ + where: { tenantId, deletedAt: null }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + prisma.bulkUpload.count({ where: { tenantId, deletedAt: null } }), + ]); + return { items, total, page, limit }; + } + + async generateErrorReport(bulkUploadId: string, tenantId: string): Promise { + const bulk = await prisma.bulkUpload.findFirst({ + where: { id: bulkUploadId, tenantId, deletedAt: null }, + include: { rows: { where: { status: { in: ['invalid', 'failed'] } }, orderBy: { rowNumber: 'asc' } } }, + }); + + if (!bulk) throw new AppError(404, 'Bulk upload not found', 'NOT_FOUND'); + + const escapeCsv = (val: string) => { + if (!val) return ''; + const s = String(val); + if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; + return s; + }; + + const header = 'Row Number,Status,Errors,Warnings,Suggestion\n'; + const rows = bulk.rows.map(r => { + const errors = Array.isArray(r.errors) ? r.errors.join('; ') : ''; + const warnings = Array.isArray(r.warnings) ? r.warnings.join('; ') : ''; + const suggestion = errors.includes('Invalid or missing amount') ? 'Check the amount column has a valid positive number' : + errors.includes('Missing destination') ? 'Ensure destination address is provided' : 'Review the row data'; + return `${r.rowNumber},${escapeCsv(r.status)},${escapeCsv(errors)},${escapeCsv(warnings)},${escapeCsv(suggestion)}`; + }).join('\n'); + + return header + rows; + } +} + +export const bulkProcessorService = new BulkProcessorService(); diff --git a/backend/src/services/reports/reconciliation-report.ts b/backend/src/services/reports/reconciliation-report.ts new file mode 100644 index 00000000..3d820549 --- /dev/null +++ b/backend/src/services/reports/reconciliation-report.ts @@ -0,0 +1,347 @@ +import { prisma } from '../../lib/prisma.js'; +import { AppError } from '../../middleware/errorHandler.js'; + +interface ReconciliationFilters { + dateFrom: string; + dateTo: string; + tenantId: string; + chain?: string; + status?: string; + format?: 'csv' | 'pdf'; +} + +interface ReportRow { + txId: string; + date: string; + chain: string; + amount: string; + fee: string; + net: string; + sender: string; + receiver: string; + status: string; + memo: string; +} + +interface DailySummary { + date: string; + chain: string; + txCount: number; + totalAmount: string; + totalFee: string; + totalNet: string; +} + +interface WeeklySummary { + week: string; + chain: string; + txCount: number; + totalAmount: string; + totalFee: string; + totalNet: string; +} + +interface MonthlySummary { + month: string; + chain: string; + txCount: number; + totalAmount: string; + totalFee: string; + totalNet: string; +} + +interface ReconciliationReport { + rows: ReportRow[]; + dailySummaries: DailySummary[]; + weeklySummaries: WeeklySummary[]; + monthlySummaries: MonthlySummary[]; + dateFrom: string; + dateTo: string; + generatedAt: string; +} + +function escapeCsv(val: string): string { + if (val == null) return ''; + const s = String(val); + if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; +} + +function toCsv(rows: ReportRow[], summaries: DailySummary[] | WeeklySummary[] | MonthlySummary[], summaryLabel: string): string { + const header = ['Transaction ID,Date,Chain,Amount,Fee,Net,Sender,Receiver,Status,Memo']; + const dataRows = rows.map(r => + [escapeCsv(r.txId), escapeCsv(r.date), escapeCsv(r.chain), escapeCsv(r.amount), escapeCsv(r.fee), escapeCsv(r.net), escapeCsv(r.sender), escapeCsv(r.receiver), escapeCsv(r.status), escapeCsv(r.memo)].join(',') + ); + const summaryHeader = [`\n${summaryLabel} Summary\n${summaryLabel},Chain,Transactions,Total Amount,Total Fee,Total Net`]; + const summaryRows = summaries.map(s => + [escapeCsv(s.date || s.week || s.month), escapeCsv(s.chain), String(s.txCount), escapeCsv(s.totalAmount), escapeCsv(s.totalFee), escapeCsv(s.totalNet)].join(',') + ); + return [header.join(''), ...dataRows, '', ...summaryHeader, ...summaryRows].join('\n'); +} + +function buildSimplePdf(report: ReconciliationReport): Buffer { + const lines: string[] = []; + lines.push('Payment Reconciliation Report'); + lines.push('='.repeat(60)); + lines.push(`Period: ${report.dateFrom} to ${report.dateTo}`); + lines.push(`Generated: ${report.generatedAt}`); + lines.push(''); + lines.push('Transaction Details'); + lines.push('-'.repeat(60)); + lines.push('TX ID | Date | Chain | Amount | Fee | Net | Status'); + lines.push('-'.repeat(60)); + + for (const row of report.rows.slice(0, 100)) { + const txId = row.txId.padEnd(16).slice(0, 16); + const date = row.date.padEnd(10).slice(0, 10); + const chain = row.chain.padEnd(8).slice(0, 8); + const amount = row.amount.padEnd(8).slice(0, 8); + const fee = row.fee.padEnd(8).slice(0, 8); + const net = row.net.padEnd(8).slice(0, 8); + const status = row.status.padEnd(6).slice(0, 6); + lines.push(`${txId} | ${date} | ${chain} | ${amount} | ${fee} | ${net} | ${status}`); + } + + if (report.rows.length > 100) { + lines.push(`... and ${report.rows.length - 100} more transactions`); + } + + lines.push(''); + lines.push('Monthly Summary'); + lines.push('-'.repeat(60)); + lines.push('Month | Chain | Txs | Amount | Fee | Net'); + lines.push('-'.repeat(60)); + + for (const m of report.monthlySummaries) { + const month = m.month.padEnd(10).slice(0, 10); + const chain = m.chain.padEnd(8).slice(0, 8); + const txs = String(m.txCount).padEnd(4).slice(0, 4); + const totalAmount = m.totalAmount.padEnd(10).slice(0, 10); + const totalFee = m.totalFee.padEnd(10).slice(0, 10); + const totalNet = m.totalNet.padEnd(10).slice(0, 10); + lines.push(`${month} | ${chain} | ${txs} | ${totalAmount} | ${totalFee} | ${totalNet}`); + } + + const text = lines.join('\n'); + const buffer = Buffer.from( + 'PDF-1.4\n%\xFF\xFF\xFF\xFF\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n' + + '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n' + + '3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' + + '/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n' + + '4 0 obj\n<< /Length ' + text.length + ' >>\nstream\n' + + text.replace(/\(/g, '\\(').replace(/\)/g, '\\)') + '\n' + + 'endstream\nendobj\n' + + '5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n' + + 'xref\n0 6\n' + + '0000000000 65535 f \n' + + '0000000009 00000 n \n' + + '0000000058 00000 n \n' + + '0000000115 00000 n \n' + + '0000000266 00000 n \n' + + '0000000378 00000 n \n' + + 'trailer\n<< /Size 6 /Root 1 0 R >>\n' + + 'startxref\n446\n%%EOF' + ); + return buffer; +} + +export class ReconciliationReportService { + async generateReport(filters: ReconciliationFilters): Promise<{ report: ReconciliationReport; csv: string; pdf: Buffer }> { + const { dateFrom, dateTo, tenantId, chain, status } = filters; + + const where: any = { + tenantId, + createdAt: { + gte: new Date(dateFrom), + lte: new Date(dateTo + 'T23:59:59.999Z'), + }, + deletedAt: null, + }; + + if (chain) where.network = chain; + if (status) where.status = status; + + const payments = await prisma.payment.findMany({ + where, + orderBy: { createdAt: 'asc' }, + take: 10000, + }); + + const rows: ReportRow[] = payments.map(p => ({ + txId: p.txHash || p.id, + date: p.createdAt.toISOString().split('T')[0], + chain: p.network, + amount: p.amount.toString(), + fee: '0', + net: p.amount.toString(), + sender: p.fromAddress || '', + receiver: p.toAddress || '', + status: p.status, + memo: p.projectTitle || '', + })); + + const chainGroups: Record = {}; + for (const r of rows) { + if (!chainGroups[r.chain]) chainGroups[r.chain] = []; + chainGroups[r.chain].push(r); + } + + const getDateKey = (d: string) => d; + const getWeekKey = (d: string) => { + const date = new Date(d); + const start = new Date(date); + start.setDate(date.getDate() - date.getDay()); + return start.toISOString().split('T')[0]; + }; + const getMonthKey = (d: string) => d.slice(0, 7); + + const aggregate = (grouped: Record) => { + const entries: { date: string; chain: string; txCount: number; totalAmount: number; totalFee: number; totalNet: number }[] = []; + for (const [key, items] of Object.entries(grouped)) { + entries.push({ + date: key, + chain: items[0].chain, + txCount: items.length, + totalAmount: items.reduce((s, r) => s + parseFloat(r.amount || '0'), 0), + totalFee: items.reduce((s, r) => s + parseFloat(r.fee || '0'), 0), + totalNet: items.reduce((s, r) => s + parseFloat(r.net || '0'), 0), + }); + } + return entries.sort((a, b) => a.date.localeCompare(b.date)); + }; + + const dailyGroups: Record = {}; + const weeklyGroups: Record = {}; + const monthlyGroups: Record = {}; + + for (const r of rows) { + const dk = getDateKey(r.date); + if (!dailyGroups[dk]) dailyGroups[dk] = []; + dailyGroups[dk].push(r); + + const wk = getWeekKey(r.date); + if (!weeklyGroups[wk]) weeklyGroups[wk] = []; + weeklyGroups[wk].push(r); + + const mk = getMonthKey(r.date); + if (!monthlyGroups[mk]) monthlyGroups[mk] = []; + monthlyGroups[mk].push(r); + } + + const formatSummary = (entries: any[], labelKey: string): any[] => + entries.map(e => ({ + [labelKey]: e.date, + chain: e.chain, + txCount: e.txCount, + totalAmount: e.totalAmount.toFixed(8), + totalFee: e.totalFee.toFixed(8), + totalNet: e.totalNet.toFixed(8), + })); + + const dailySummaries = formatSummary(aggregate(dailyGroups), 'date') as DailySummary[]; + const weeklySummaries = formatSummary(aggregate(weeklyGroups), 'week') as WeeklySummary[]; + const monthlySummaries = formatSummary(aggregate(monthlyGroups), 'month') as MonthlySummary[]; + + const report: ReconciliationReport = { + rows, + dailySummaries, + weeklySummaries, + monthlySummaries, + dateFrom, + dateTo, + generatedAt: new Date().toISOString(), + }; + + const csv = toCsv(rows, dailySummaries, 'Daily'); + const pdf = buildSimplePdf(report); + + return { report, csv, pdf }; + } + + async saveReportJob( + tenantId: string, + userId: string | undefined, + dateFrom: string, + dateTo: string, + format: string, + filters: any, + ) { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 90); + + const job = await prisma.reportJob.create({ + data: { + tenantId, + userId, + type: 'reconciliation', + status: 'pending', + dateFrom: new Date(dateFrom), + dateTo: new Date(dateTo), + format, + filters, + expiresAt, + }, + }); + return job; + } + + async updateJobStatus(jobId: string, status: string, errorMessage?: string) { + const data: any = { status }; + if (status === 'generating') { + data.progress = 50; + } + if (status === 'completed') { + data.progress = 100; + data.completedAt = new Date(); + } + if (errorMessage) { + data.errorMessage = errorMessage; + } + return prisma.reportJob.update({ where: { id: jobId }, data }); + } + + async createArchive(jobId: string, format: string, fileUrl: string, rowCount: number, fileSize: number) { + return prisma.reportArchive.create({ + data: { reportId: jobId, format, fileUrl, rowCount, fileSize }, + }); + } + + async getJob(jobId: string, tenantId: string) { + const job = await prisma.reportJob.findFirst({ + where: { id: jobId, tenantId, deletedAt: null }, + include: { archives: true }, + }); + if (!job) throw new AppError(404, 'Report job not found', 'NOT_FOUND'); + return job; + } + + async listJobs(tenantId: string, page: number = 1, limit: number = 20) { + const skip = (page - 1) * limit; + const [jobs, total] = await Promise.all([ + prisma.reportJob.findMany({ + where: { tenantId, deletedAt: null }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include: { archives: true }, + }), + prisma.reportJob.count({ where: { tenantId, deletedAt: null } }), + ]); + return { jobs, total, page, limit }; + } + + async deleteJob(jobId: string, tenantId: string) { + const job = await prisma.reportJob.findFirst({ + where: { id: jobId, tenantId, deletedAt: null }, + }); + if (!job) throw new AppError(404, 'Report job not found', 'NOT_FOUND'); + return prisma.reportJob.update({ + where: { id: jobId }, + data: { deletedAt: new Date() }, + }); + } +} + +export const reconciliationReportService = new ReconciliationReportService(); diff --git a/frontend/app/[locale]/dashboard/reports/reconciliation/page.tsx b/frontend/app/[locale]/dashboard/reports/reconciliation/page.tsx new file mode 100644 index 00000000..b6b15b37 --- /dev/null +++ b/frontend/app/[locale]/dashboard/reports/reconciliation/page.tsx @@ -0,0 +1,360 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Download, + FileText, + Eye, + Calendar, + Loader2, +} from 'lucide-react'; + +interface ReportPreview { + rows: any[]; + totalRows: number; + dailySummaries: { date: string; chain: string; txCount: number; totalAmount: string; totalFee: string; totalNet: string }[]; + weeklySummaries: { week: string; chain: string; txCount: number; totalAmount: string; totalFee: string; totalNet: string }[]; + monthlySummaries: { month: string; chain: string; txCount: number; totalAmount: string; totalFee: string; totalNet: string }[]; + generatedAt: string; +} + +export default function ReconciliationReportPage() { + const t = useTranslations('dashboard'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [chain, setChain] = useState(''); + const [status, setStatus] = useState(''); + const [format, setFormat] = useState<'csv' | 'pdf'>('csv'); + const [loading, setLoading] = useState(false); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + + const handlePreview = useCallback(async () => { + if (!dateFrom || !dateTo) { + setError('Start and end dates are required'); + return; + } + + setLoading(true); + setError(null); + const tenantId = 'default'; + + try { + const res = await fetch('/api/v1/reconciliation/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-tenant-id': tenantId }, + body: JSON.stringify({ dateFrom, dateTo, chain: chain || undefined, status: status || undefined }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || 'Preview failed'); + setPreview(data); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [dateFrom, dateTo, chain, status]); + + const handleDownload = useCallback(async () => { + if (!dateFrom || !dateTo) return; + + setLoading(true); + setError(null); + const tenantId = 'default'; + + try { + const res = await fetch('/api/v1/reconciliation/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-tenant-id': tenantId }, + body: JSON.stringify({ dateFrom, dateTo, chain: chain || undefined, status: status || undefined, format }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || 'Generation failed'); + } + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `reconciliation-${dateFrom}-${dateTo}.${format}`; + a.click(); + URL.revokeObjectURL(url); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [dateFrom, dateTo, chain, status, format]); + + return ( +
+
+

Payment Reconciliation Report

+

+ Generate reconciliation reports matching on-chain settlements against internal records +

+
+ + + + + Report Parameters + + + +
+
+ + setDateFrom(e.target.value)} /> +
+
+ + setDateTo(e.target.value)} /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {error && ( +
{error}
+ )} +
+
+ + {preview && ( + <> +
+ + Total Transactions +
{preview.totalRows}
+
+ + Date Range +
{preview.dateFrom} to {preview.dateTo}
+
+ + Daily Summaries +
{preview.dailySummaries.length}
+
+ + Generated +
{new Date(preview.generatedAt).toLocaleString()}
+
+
+ + + + + Report Data + + + Showing {Math.min(preview.rows.length, 100)} of {preview.totalRows} rows + + + + + + Transactions + Daily Summary + Weekly Summary + Monthly Summary + + + +
+ + + + TX ID + Date + Chain + Amount + Fee + Net + Sender + Receiver + Status + + + + {preview.rows.map((row: any, i: number) => ( + + {row.txId.slice(0, 12)}... + {row.date} + {row.chain} + {row.amount} + {row.fee} + {row.net} + {row.sender} + {row.receiver} + {row.status} + + ))} + +
+
+
+ + +
+ + + + Date + Chain + Transactions + Total Amount + Total Fee + Total Net + + + + {preview.dailySummaries.map((s, i) => ( + + {s.date} + {s.chain} + {s.txCount} + {s.totalAmount} + {s.totalFee} + {s.totalNet} + + ))} + +
+
+
+ + +
+ + + + Week Starting + Chain + Transactions + Total Amount + Total Fee + Total Net + + + + {preview.weeklySummaries.map((s, i) => ( + + {s.week} + {s.chain} + {s.txCount} + {s.totalAmount} + {s.totalFee} + {s.totalNet} + + ))} + +
+
+
+ + +
+ + + + Month + Chain + Transactions + Total Amount + Total Fee + Total Net + + + + {preview.monthlySummaries.map((s, i) => ( + + {s.month} + {s.chain} + {s.txCount} + {s.totalAmount} + {s.totalFee} + {s.totalNet} + + ))} + +
+
+
+
+
+
+ + )} +
+ ); +} diff --git a/frontend/app/[locale]/payments/bulk/page.tsx b/frontend/app/[locale]/payments/bulk/page.tsx new file mode 100644 index 00000000..b8c5a603 --- /dev/null +++ b/frontend/app/[locale]/payments/bulk/page.tsx @@ -0,0 +1,541 @@ +'use client'; + +import { useState, useRef, useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Upload, + Download, + CheckCircle2, + XCircle, + AlertTriangle, + ArrowLeft, + Play, + FileText, + AlertCircle, +} from 'lucide-react'; + +interface ValidationRow { + rowNumber: number; + status: 'valid' | 'invalid'; + errors: string[]; + warnings: string[]; + parsed?: { + amount: number; + destination: string; + currency?: string; + memo?: string; + chain?: string; + }; +} + +interface ColumnMapping { + amount: string; + destination: string; + currency?: string; + memo?: string; + chain?: string; +} + +export default function BulkPaymentPage() { + const t = useTranslations('payments'); + const fileInputRef = useRef(null); + + const [step, setStep] = useState<'upload' | 'map' | 'validate' | 'process' | 'complete'>('upload'); + const [file, setFile] = useState(null); + const [rawData, setRawData] = useState([]); + const [headers, setHeaders] = useState([]); + const [columnMapping, setColumnMapping] = useState({ + amount: '', + destination: '', + }); + const [validationResult, setValidationResult] = useState<{ + rows: ValidationRow[]; + validCount: number; + errorCount: number; + } | null>(null); + const [bulkId, setBulkId] = useState(null); + const [processing, setProcessing] = useState(false); + const [processProgress, setProcessProgress] = useState(0); + const [processResult, setProcessResult] = useState<{ + processedCount: number; + failedCount: number; + status: string; + } | null>(null); + const [error, setError] = useState(null); + + const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (!selectedFile) return; + + setFile(selectedFile); + setError(null); + + const text = await selectedFile.text(); + const lines = text.split('\n').filter(l => l.trim()); + if (lines.length < 2) { + setError('File must have a header row and at least one data row'); + return; + } + + const parsedHeaders = lines[0].split(',').map(h => h.trim().toLowerCase()); + setHeaders(parsedHeaders); + + const data = lines.slice(1).map(line => { + const values = line.split(',').map(v => v.trim()); + const row: any = {}; + parsedHeaders.forEach((h, i) => { row[h] = values[i] || ''; }); + return row; + }); + setRawData(data); + + const autoMap: ColumnMapping = { amount: '', destination: '' }; + for (const h of parsedHeaders) { + if (['amount', 'price', 'value', 'sum'].includes(h)) autoMap.amount = h; + else if (['destination', 'to', 'address', 'recipient', 'wallet'].includes(h)) autoMap.destination = h; + else if (['currency', 'token', 'asset'].includes(h)) autoMap.currency = h; + else if (['memo', 'note', 'reference', 'description'].includes(h)) autoMap.memo = h; + else if (['chain', 'network', 'blockchain'].includes(h)) autoMap.chain = h; + } + setColumnMapping(autoMap); + setStep('map'); + }, []); + + const handleValidate = useCallback(async () => { + if (!columnMapping.amount || !columnMapping.destination) { + setError('Amount and Destination column mappings are required'); + return; + } + + setProcessing(true); + setError(null); + const tenantId = 'default'; + + try { + const res = await fetch('/api/v1/payments/bulk/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-tenant-id': tenantId }, + body: JSON.stringify({ rows: rawData, columnMapping }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || 'Validation failed'); + + setValidationResult(data); + setStep('validate'); + } catch (err: any) { + setError(err.message); + } finally { + setProcessing(false); + } + }, [columnMapping, rawData]); + + const handleUpload = useCallback(async () => { + if (!validationResult || validationResult.validCount === 0) return; + + setProcessing(true); + setError(null); + const tenantId = 'default'; + + try { + const res = await fetch('/api/v1/payments/bulk/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-tenant-id': tenantId }, + body: JSON.stringify({ + rows: rawData, + columnMapping, + fileName: file?.name || 'upload.csv', + fileSize: file?.size || 0, + mimeType: file?.type || 'text/csv', + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || 'Upload failed'); + + setBulkId(data.bulkUploadId); + setStep('process'); + } catch (err: any) { + setError(err.message); + } finally { + setProcessing(false); + } + }, [validationResult, columnMapping, rawData, file]); + + const handleProcess = useCallback(async () => { + if (!bulkId) return; + + setProcessing(true); + setError(null); + const tenantId = 'default'; + + try { + const res = await fetch(`/api/v1/payments/bulk/${bulkId}/process`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-tenant-id': tenantId }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || 'Processing failed'); + + setProcessResult(data); + setProcessProgress(100); + setStep('complete'); + } catch (err: any) { + setError(err.message); + } finally { + setProcessing(false); + } + }, [bulkId]); + + const downloadTemplate = useCallback(async () => { + const res = await fetch('/api/v1/payments/bulk/template'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'bulk-payment-template.csv'; + a.click(); + URL.revokeObjectURL(url); + }, []); + + const downloadErrorReport = useCallback(async () => { + if (!bulkId) return; + const tenantId = 'default'; + const res = await fetch(`/api/v1/payments/bulk/${bulkId}/error-report`, { + headers: { 'x-tenant-id': tenantId }, + }); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bulk-errors-${bulkId}.csv`; + a.click(); + URL.revokeObjectURL(url); + }, [bulkId]); + + const reset = useCallback(() => { + setStep('upload'); + setFile(null); + setRawData([]); + setHeaders([]); + setColumnMapping({ amount: '', destination: '' }); + setValidationResult(null); + setBulkId(null); + setProcessResult(null); + setProcessProgress(0); + setError(null); + }, []); + + return ( +
+
+
+

{t('bulkPayments') || 'Bulk Payments'}

+

+ Upload CSV files to process multiple payments at once +

+
+ {step !== 'upload' && ( + + )} +
+ + {error && ( + + + Error + {error} + + )} + + {step === 'upload' && ( + + + Upload Payment File + Upload a CSV or Excel file with your payment data + + +
fileInputRef.current?.click()} + > + +

Click to upload or drag and drop

+

CSV files recommended

+ +
+
+ +
+
+
+ )} + + {step === 'map' && ( + + + Map Columns + + Found {headers.length} columns and {rawData.length} rows. Map your CSV columns to payment fields. + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ )} + + {step === 'validate' && validationResult && ( + + + + Validation Results + + {validationResult.validCount} valid + + {validationResult.errorCount > 0 && ( + + {validationResult.errorCount} errors + + )} + + + Review and fix any errors before proceeding + + + +
+ + + + Row + Status + Amount + Destination + Currency + Errors / Warnings + + + + {validationResult.rows.map((row) => ( + + {row.rowNumber} + + {row.status === 'valid' ? ( + + ) : ( + + )} + + {row.parsed?.amount ?? '-'} + + {row.parsed?.destination ?? '-'} + + {row.parsed?.currency ?? '-'} + + {row.errors.map((e, i) => ( +
+ {e} +
+ ))} + {row.warnings.map((w, i) => ( +
+ {w} +
+ ))} +
+
+ ))} +
+
+
+
+ + +
+
+
+ )} + + {step === 'process' && ( + + + Execute Payments + + Process {validationResult?.validCount || 0} validated payments + + + + + + Ready to process + + This will create {validationResult?.validCount || 0} payment transactions. + Monitor progress below after starting. + + + {processProgress > 0 && ( +
+
+ Progress + {processProgress}% +
+ +
+ )} +
+ + +
+
+
+ )} + + {step === 'complete' && processResult && ( + + + + + Processing Complete + + + +
+
+
{processResult.processedCount}
+
Succeeded
+
+
+
{processResult.failedCount}
+
Failed
+
+
+ + {processResult.status} + +
Status
+
+
+ {processResult.failedCount > 0 && ( + + )} +
+ +
+
+
+ )} +
+ ); +}