diff --git a/backend/src/container.ts b/backend/src/container.ts index e16171b..f1c3faa 100644 --- a/backend/src/container.ts +++ b/backend/src/container.ts @@ -20,6 +20,7 @@ import { RecurringPaymentService } from "./modules/recurring-payments/recurringP import { RecurringPaymentWorker } from "./modules/recurring-payments/recurringPaymentWorker"; import { InMemoryRecurringPaymentRepository } from "./modules/recurring-payments/inMemoryRecurringPaymentRepository"; import { ComplianceLogService } from "./modules/compliance/complianceLogService"; +import { ComplianceViolationAlertService } from "./modules/compliance/complianceViolationAlertService"; import { AuditStorageService } from "./modules/compliance/auditStorageService"; import { ReconciliationService } from "./modules/reconciliation/reconciliationService"; import { StressTestService } from "./modules/stress/stressTestService"; @@ -49,7 +50,9 @@ export interface AppContainer { countryMetadata: CountryMetadataService; compliance: ComplianceService; complianceLog: ComplianceLogService; + complianceViolations: ComplianceViolationAlertService; fraud: FraudService; + fraudReview: FraudReviewService; notifications: NotificationService; notification: NotificationService; activity: ActivityService; @@ -62,6 +65,8 @@ export interface AppContainer { adminAlerts: AdminAlertService; operationalMetrics: OperationalMetricsService; auditStorage: AuditStorageService; + reconciliation: ReconciliationService; + stressTest: StressTestService; authRiskEngine: AuthRiskEngine; deadLetterQueue: DeadLetterQueue; settlementAnalytics: SettlementAnalyticsService; @@ -80,7 +85,7 @@ export function createContainer(): AppContainer { const compliance = new ComplianceService(); const fraud = new FraudService(); const wallets = new WalletService(); - const contracts = new ContractService(); + const contracts = new ContractService(eventBus); const countryMetadata = new CountryMetadataService(); const transferRepository = new InMemoryTransferRepository( createDemoTransfers(), @@ -124,6 +129,12 @@ export function createContainer(): AppContainer { const authRiskEngine = new AuthRiskEngine(eventBus); const settlementAnalytics = new SettlementAnalyticsService(eventBus); const adminAlerts = new AdminAlertService(eventBus); + const complianceViolations = new ComplianceViolationAlertService( + eventBus, + complianceLog, + adminAlerts, + contracts.complianceLimits, + ); const operationalMetrics = new OperationalMetricsService(); const transactionApproval = new TransactionApprovalService(); const successRate = new SuccessRateService(); @@ -174,8 +185,10 @@ export function createContainer(): AppContainer { countryMetadata, compliance, complianceLog, + complianceViolations, fraud, - fraudReview, + fraudReview, + notifications, notification: notifications, activity, health, @@ -187,6 +200,8 @@ export function createContainer(): AppContainer { adminAlerts, operationalMetrics, auditStorage, + reconciliation, + stressTest, authRiskEngine, deadLetterQueue, settlementAnalytics, diff --git a/backend/src/modules/compliance/__tests__/complianceViolationAlertService.test.ts b/backend/src/modules/compliance/__tests__/complianceViolationAlertService.test.ts new file mode 100644 index 0000000..d65b93e --- /dev/null +++ b/backend/src/modules/compliance/__tests__/complianceViolationAlertService.test.ts @@ -0,0 +1,164 @@ +import { EventBus } from '../../../core/eventBus'; +import { AdminAlertService } from '../../system/adminAlertService'; +import { ComplianceLogService } from '../complianceLogService'; +import { ComplianceViolationAlertService } from '../complianceViolationAlertService'; +import type { ContractEvent } from '../../../services/contractService'; + +describe('ComplianceViolationAlertService', () => { + let eventBus: EventBus; + let complianceLogs: ComplianceLogService; + let adminAlerts: AdminAlertService; + let service: ComplianceViolationAlertService; + + beforeEach(() => { + eventBus = new EventBus(); + complianceLogs = new ComplianceLogService(eventBus); + adminAlerts = new AdminAlertService(eventBus); + service = new ComplianceViolationAlertService( + eventBus, + complianceLogs, + adminAlerts, + 'compliance_contract', + ); + }); + + it('creates an admin alert when a compliance log is blocked', async () => { + const log = complianceLogs.createLog({ + userId: 'user_123', + transferId: 'transfer_123', + checkType: 'aml', + status: 'blocked', + riskScore: 85, + flags: ['high_risk_country', 'very_large_amount'], + metadata: { amount: 15000 }, + checkedBy: 'system', + }); + + await new Promise(process.nextTick); + + const violations = service.getViolations(); + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual( + expect.objectContaining({ + source: 'compliance_log', + severity: 'critical', + userId: 'user_123', + transferId: 'transfer_123', + complianceLogId: log.id, + }), + ); + + const alerts = adminAlerts.getAlerts(); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toEqual( + expect.objectContaining({ + type: 'compliance_violation', + severity: 'critical', + transferId: 'transfer_123', + userId: 'user_123', + }), + ); + }); + + it('ignores passing compliance logs', async () => { + complianceLogs.createLog({ + userId: 'user_123', + checkType: 'aml', + status: 'passed', + riskScore: 5, + flags: [], + metadata: {}, + checkedBy: 'system', + }); + + await new Promise(process.nextTick); + + expect(service.getViolations()).toHaveLength(0); + expect(adminAlerts.getAlerts()).toHaveLength(0); + }); + + it('detects compliance contract events that deny a transfer', async () => { + const contractEvent: ContractEvent = { + id: 'evt_blocked', + contractId: 'compliance_contract', + method: 'inspect', + args: ['user_abc', 12000], + result: { + allowed: false, + status: 'blocked', + riskScore: 90, + flags: ['daily_limit_exceeded'], + transferId: 'transfer_abc', + }, + timestamp: new Date().toISOString(), + }; + + await eventBus.publish({ + type: 'contract.event_recorded', + timestamp: contractEvent.timestamp, + payload: contractEvent, + }); + + const violations = service.getViolations({ source: 'contract_event' }); + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual( + expect.objectContaining({ + severity: 'critical', + userId: 'user_abc', + transferId: 'transfer_abc', + sourceEventId: 'evt_blocked', + }), + ); + expect(adminAlerts.getAlerts()[0].type).toBe('compliance_violation'); + }); + + it('deduplicates repeated source events', async () => { + const contractEvent: ContractEvent = { + id: 'evt_duplicate', + contractId: 'compliance_contract', + method: 'limit_exceeded', + args: ['user_dup', 9000], + result: { violation: true, flags: ['monthly_limit_exceeded'] }, + timestamp: new Date().toISOString(), + }; + + await eventBus.publish({ + type: 'contract.event_recorded', + timestamp: contractEvent.timestamp, + payload: contractEvent, + }); + await eventBus.publish({ + type: 'contract.event_recorded', + timestamp: contractEvent.timestamp, + payload: contractEvent, + }); + + expect(service.getViolations()).toHaveLength(1); + expect(adminAlerts.getAlerts()).toHaveLength(1); + }); + + it('generates a compliance violation report', async () => { + complianceLogs.createLog({ + userId: 'user_report', + checkType: 'sanctions', + status: 'flagged', + riskScore: 55, + flags: ['sanctions_review'], + metadata: {}, + checkedBy: 'system', + }); + + await new Promise(process.nextTick); + + const report = service.generateReport({ userId: 'user_report' }); + expect(report.summary).toEqual( + expect.objectContaining({ + total: 1, + medium: 1, + unresolved: 0, + }), + ); + expect(report.summary.bySource.compliance_log).toBe(1); + expect(report.violations[0].userId).toBe('user_report'); + }); +}); diff --git a/backend/src/modules/compliance/auditStorageService.ts b/backend/src/modules/compliance/auditStorageService.ts index 2e0d378..159b052 100644 --- a/backend/src/modules/compliance/auditStorageService.ts +++ b/backend/src/modules/compliance/auditStorageService.ts @@ -257,8 +257,6 @@ export class AuditStorageService { private getLatestRecord(): AuditRecord | undefined { const records = Array.from(this.store.values()); if (records.length === 0) return undefined; - return records.reduce((latest, record) => - new Date(record.storedAt).getTime() > new Date(latest.storedAt).getTime() ? record : latest, - ); + return records[records.length - 1]; } } diff --git a/backend/src/modules/compliance/complianceLogService.ts b/backend/src/modules/compliance/complianceLogService.ts index b13c30f..4f8e437 100644 --- a/backend/src/modules/compliance/complianceLogService.ts +++ b/backend/src/modules/compliance/complianceLogService.ts @@ -180,6 +180,10 @@ export class ComplianceLogService { return log; } + getLogById(logId: string): ComplianceLog | null { + return this.logs.find((log) => log.id === logId) ?? null; + } + /** * Get logs for a specific user */ diff --git a/backend/src/modules/compliance/complianceViolationAlertService.ts b/backend/src/modules/compliance/complianceViolationAlertService.ts new file mode 100644 index 0000000..8bc1a52 --- /dev/null +++ b/backend/src/modules/compliance/complianceViolationAlertService.ts @@ -0,0 +1,369 @@ +import { createLogger } from '../../logger'; +import type { DomainEvent, EventBus } from '../../core/eventBus'; +import type { ContractEvent } from '../../services/contractService'; +import type { AdminAlertService, AlertSeverity } from '../system/adminAlertService'; +import type { ComplianceCheckStatus, ComplianceLogService } from './complianceLogService'; + +export type ComplianceViolationSource = 'contract_event' | 'compliance_log'; + +export interface ComplianceViolationRecord { + id: string; + source: ComplianceViolationSource; + severity: AlertSeverity; + title: string; + description: string; + userId?: string; + transferId?: string; + contractId?: string; + contractMethod?: string; + sourceEventId?: string; + complianceLogId?: string; + status?: ComplianceCheckStatus; + riskScore?: number; + flags: string[]; + metadata: Record; + detectedAt: string; + alertId?: string; +} + +export interface ComplianceViolationReport { + summary: { + total: number; + critical: number; + high: number; + medium: number; + low: number; + bySource: Record; + unresolved: number; + }; + violations: ComplianceViolationRecord[]; +} + +export class ComplianceViolationAlertService { + private readonly logger = createLogger({ component: 'complianceViolationAlertService' }); + private readonly violations: ComplianceViolationRecord[] = []; + private readonly seenSources = new Set(); + + constructor( + private readonly eventBus: EventBus, + private readonly complianceLogs: ComplianceLogService, + private readonly adminAlerts: AdminAlertService, + private readonly complianceContractId?: string, + ) { + this.subscribeToEvents(); + } + + getViolations(filters?: { + severity?: AlertSeverity; + source?: ComplianceViolationSource; + userId?: string; + transferId?: string; + fromDate?: string; + toDate?: string; + limit?: number; + }): ComplianceViolationRecord[] { + let filtered = [...this.violations]; + + if (filters?.severity) { + filtered = filtered.filter((violation) => violation.severity === filters.severity); + } + if (filters?.source) { + filtered = filtered.filter((violation) => violation.source === filters.source); + } + if (filters?.userId) { + filtered = filtered.filter((violation) => violation.userId === filters.userId); + } + if (filters?.transferId) { + filtered = filtered.filter((violation) => violation.transferId === filters.transferId); + } + if (filters?.fromDate) { + const from = new Date(filters.fromDate).getTime(); + if (Number.isFinite(from)) { + filtered = filtered.filter((violation) => new Date(violation.detectedAt).getTime() >= from); + } + } + if (filters?.toDate) { + const to = new Date(filters.toDate).getTime(); + if (Number.isFinite(to)) { + filtered = filtered.filter((violation) => new Date(violation.detectedAt).getTime() <= to); + } + } + + return filtered.slice(0, filters?.limit ?? 100); + } + + generateReport(filters?: Parameters[0]): ComplianceViolationReport { + const violations = this.getViolations(filters); + return { + summary: { + total: violations.length, + critical: violations.filter((violation) => violation.severity === 'critical').length, + high: violations.filter((violation) => violation.severity === 'high').length, + medium: violations.filter((violation) => violation.severity === 'medium').length, + low: violations.filter((violation) => violation.severity === 'low').length, + bySource: { + contract_event: violations.filter((violation) => violation.source === 'contract_event').length, + compliance_log: violations.filter((violation) => violation.source === 'compliance_log').length, + }, + unresolved: violations.filter((violation) => !violation.alertId).length, + }, + violations, + }; + } + + private subscribeToEvents(): void { + this.eventBus.subscribe<{ + logId: string; + userId: string; + checkType: string; + status: ComplianceCheckStatus; + riskScore: number; + }>('compliance.log_created', async (event) => { + this.handleComplianceLogEvent(event); + }); + + this.eventBus.subscribe('contract.event_recorded', async (event) => { + this.handleContractEvent(event); + }); + } + + private handleComplianceLogEvent(event: DomainEvent<{ + logId: string; + userId: string; + checkType: string; + status: ComplianceCheckStatus; + riskScore: number; + }>): void { + if (!['flagged', 'blocked', 'manual_review'].includes(event.payload.status)) { + return; + } + + const sourceKey = `log:${event.payload.logId}`; + if (this.seenSources.has(sourceKey)) { + return; + } + + const log = this.complianceLogs.getLogById(event.payload.logId); + const severity = this.getSeverity(event.payload.status, event.payload.riskScore); + const flags = log?.flags ?? []; + + this.recordViolation({ + sourceKey, + source: 'compliance_log', + severity, + title: 'Compliance Violation Detected', + description: `${event.payload.checkType.toUpperCase()} check ${event.payload.status.replace('_', ' ')} with risk score ${event.payload.riskScore}.`, + userId: event.payload.userId, + transferId: log?.transferId, + complianceLogId: event.payload.logId, + status: event.payload.status, + riskScore: event.payload.riskScore, + flags, + metadata: { + checkType: event.payload.checkType, + ...(log?.metadata ?? {}), + }, + }); + } + + private handleContractEvent(event: DomainEvent): void { + if (!this.isComplianceContractEvent(event.payload)) { + return; + } + + const detected = this.extractContractViolation(event.payload); + if (!detected) { + return; + } + + const sourceKey = `contract:${event.payload.id}`; + if (this.seenSources.has(sourceKey)) { + return; + } + + this.recordViolation({ + sourceKey, + source: 'contract_event', + severity: detected.severity, + title: 'Compliance Contract Violation', + description: detected.description, + userId: detected.userId, + transferId: detected.transferId, + contractId: event.payload.contractId, + contractMethod: event.payload.method, + sourceEventId: event.payload.id, + status: detected.status, + riskScore: detected.riskScore, + flags: detected.flags, + metadata: { + contractArgs: event.payload.args ?? [], + contractResult: event.payload.result, + }, + }); + } + + private recordViolation(input: Omit & { sourceKey: string }): ComplianceViolationRecord { + this.seenSources.add(input.sourceKey); + + const violation: ComplianceViolationRecord = { + id: `viol_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + source: input.source, + severity: input.severity, + title: input.title, + description: input.description, + userId: input.userId, + transferId: input.transferId, + contractId: input.contractId, + contractMethod: input.contractMethod, + sourceEventId: input.sourceEventId, + complianceLogId: input.complianceLogId, + status: input.status, + riskScore: input.riskScore, + flags: input.flags, + metadata: input.metadata, + detectedAt: new Date().toISOString(), + }; + + const alert = this.adminAlerts.createAlert({ + type: 'compliance_violation', + severity: violation.severity, + title: violation.title, + message: violation.description, + transferId: violation.transferId, + userId: violation.userId, + metadata: { + violationId: violation.id, + source: violation.source, + flags: violation.flags, + riskScore: violation.riskScore, + complianceLogId: violation.complianceLogId, + contractEventId: violation.sourceEventId, + }, + }); + + violation.alertId = alert.id; + this.violations.unshift(violation); + if (this.violations.length > 500) { + this.violations.splice(500); + } + + this.logger.warn( + { violationId: violation.id, alertId: alert.id, source: violation.source, severity: violation.severity }, + 'compliance violation alert created', + ); + + void this.eventBus.publish({ + type: 'compliance.violation_detected', + timestamp: violation.detectedAt, + payload: { + violationId: violation.id, + alertId: alert.id, + source: violation.source, + severity: violation.severity, + }, + }); + + return violation; + } + + private isComplianceContractEvent(event: ContractEvent): boolean { + if (this.complianceContractId && event.contractId === this.complianceContractId) { + return true; + } + return ['inspect', 'record', 'record_violation', 'violation', 'limit_exceeded'].includes(event.method); + } + + private extractContractViolation(event: ContractEvent): { + severity: AlertSeverity; + description: string; + userId?: string; + transferId?: string; + status?: ComplianceCheckStatus; + riskScore?: number; + flags: string[]; + } | null { + const result = this.asRecord(event.result); + const args = Array.isArray(event.args) ? event.args : []; + const methodLooksLikeViolation = /violation|blocked|denied|limit_exceeded/.test(event.method); + const allowed = this.getBoolean(result, ['allowed', 'canProceed', 'passed', 'success']); + const status = this.getString(result, ['status', 'decision']); + const violation = this.getBoolean(result, ['violation', 'violated', 'limitExceeded', 'blocked']); + const flags = this.getStringArray(result, 'flags'); + const riskScore = this.getNumber(result, ['riskScore', 'risk_score']); + const isViolation = + methodLooksLikeViolation || + violation === true || + allowed === false || + status === 'blocked' || + status === 'flagged' || + status === 'manual_review' || + flags.length > 0; + + if (!isViolation) { + return null; + } + + const normalizedStatus = this.normalizeStatus(status, allowed, violation); + const severity = this.getSeverity(normalizedStatus, riskScore ?? (flags.length > 0 ? 40 : 0)); + const userId = this.getString(result, ['userId', 'user_id']) ?? (typeof args[0] === 'string' ? args[0] : undefined); + const transferId = this.getString(result, ['transferId', 'transfer_id']); + + return { + severity, + description: `Compliance contract ${event.method} reported ${normalizedStatus.replace('_', ' ')}${riskScore !== undefined ? ` with risk score ${riskScore}` : ''}.`, + userId, + transferId, + status: normalizedStatus, + riskScore, + flags, + }; + } + + private getSeverity(status: ComplianceCheckStatus, riskScore = 0): AlertSeverity { + if (status === 'blocked' || riskScore >= 80) return 'critical'; + if (status === 'manual_review' || riskScore >= 60) return 'high'; + if (status === 'flagged' || riskScore >= 30) return 'medium'; + return 'low'; + } + + private normalizeStatus(status: string | undefined, allowed?: boolean, violation?: boolean): ComplianceCheckStatus { + if (status === 'blocked' || status === 'flagged' || status === 'manual_review' || status === 'passed') { + return status; + } + if (allowed === false || violation === true) { + return 'blocked'; + } + return 'flagged'; + } + + private asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + } + + private getBoolean(record: Record, keys: string[]): boolean | undefined { + for (const key of keys) { + if (typeof record[key] === 'boolean') return record[key] as boolean; + } + return undefined; + } + + private getNumber(record: Record, keys: string[]): number | undefined { + for (const key of keys) { + if (typeof record[key] === 'number') return record[key] as number; + } + return undefined; + } + + private getString(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + if (typeof record[key] === 'string') return record[key] as string; + } + return undefined; + } + + private getStringArray(record: Record, key: string): string[] { + const value = record[key]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string'); + } +} diff --git a/backend/src/routes/compliance.ts b/backend/src/routes/compliance.ts index 60974b9..df8e476 100644 --- a/backend/src/routes/compliance.ts +++ b/backend/src/routes/compliance.ts @@ -18,6 +18,16 @@ interface AdminComplianceLogsQuery { limit?: string; } +interface AdminComplianceViolationsQuery { + severity?: "critical" | "high" | "medium" | "low"; + source?: "contract_event" | "compliance_log"; + userId?: string; + transferId?: string; + fromDate?: string; + toDate?: string; + limit?: string; +} + export default async function complianceRoutes(fastify: FastifyInstance) { const authGuards = { preHandler: [requireVerifiedSession] }; const adminGuards = { @@ -78,6 +88,23 @@ export default async function complianceRoutes(fastify: FastifyInstance) { }, ); + /** GET /admin/compliance/violations — compliance violation alert feed (admin) */ + fastify.get<{ Querystring: AdminComplianceViolationsQuery }>( + "/admin/compliance/violations", + adminGuards, + async (req) => { + return fastify.container.services.complianceViolations.getViolations({ + severity: req.query.severity, + source: req.query.source, + userId: req.query.userId, + transferId: req.query.transferId, + fromDate: req.query.fromDate, + toDate: req.query.toDate, + limit: req.query.limit ? Number(req.query.limit) : 100, + }); + }, + ); + /** POST /admin/compliance/logs/:logId/notes — update log notes (admin) */ fastify.post<{ Params: { logId: string }; Body: UpdateNotesBody }>( "/admin/compliance/logs/:logId/notes", diff --git a/backend/src/routes/reports.ts b/backend/src/routes/reports.ts index 73eb7d1..5187fc0 100644 --- a/backend/src/routes/reports.ts +++ b/backend/src/routes/reports.ts @@ -107,5 +107,34 @@ export default async function reportRoutes(fastify: FastifyInstance) { }); }, ); -} + /** + * Compliance violation report. + * GET /admin/reports/compliance/violations?fromDate&toDate&severity&source&userId&transferId + */ + fastify.get( + '/admin/reports/compliance/violations', + adminGuards, + async (req) => { + const query = req.query as { + severity?: 'critical' | 'high' | 'medium' | 'low'; + source?: 'contract_event' | 'compliance_log'; + userId?: string; + transferId?: string; + fromDate?: string; + toDate?: string; + limit?: string; + }; + + return fastify.container.services.complianceViolations.generateReport({ + severity: query.severity, + source: query.source, + userId: query.userId, + transferId: query.transferId, + fromDate: query.fromDate, + toDate: query.toDate, + limit: query.limit ? Number(query.limit) : 100, + }); + }, + ); +} diff --git a/backend/src/services/contractService.ts b/backend/src/services/contractService.ts index 6e98d3f..c09cad2 100644 --- a/backend/src/services/contractService.ts +++ b/backend/src/services/contractService.ts @@ -1,4 +1,5 @@ import { config } from '../config'; +import type { EventBus } from '../core/eventBus'; import { logger } from '../logger'; export interface ContractEvent { @@ -20,7 +21,7 @@ export class ContractService { public readonly complianceLimits?: string; public readonly recurringPayments?: string; - constructor() { + constructor(private readonly eventBus?: EventBus) { this.simpleCounter = config.contracts.simpleCounter; this.accessGuard = config.contracts.accessGuard; this.remittanceEscrow = config.contracts.remittanceEscrow; @@ -56,6 +57,13 @@ export class ContractService { } logger.info({ contractId, method, eventId: event.id }, 'contract event logged'); + + void this.eventBus?.publish({ + type: 'contract.event_recorded', + timestamp, + payload: event, + }); + return outcome; } @@ -218,4 +226,4 @@ export class ContractService { } }; } -} \ No newline at end of file +}