diff --git a/ANOMALY_DETECTION.md b/ANOMALY_DETECTION.md new file mode 100644 index 00000000..b672916b --- /dev/null +++ b/ANOMALY_DETECTION.md @@ -0,0 +1,745 @@ +# AI-Powered Anomaly Detection & Fraud Prevention Engine + +## Overview + +The Anomaly Detection & Fraud Prevention Engine uses machine learning and behavioral analysis to identify suspicious transactions, unusual spending patterns, and potential security threats in real-time. The system provides comprehensive fraud detection, risk scoring, and automated prevention mechanisms. + +## Features + +- **Machine Learning-Based Detection**: Identifies anomalies using behavioral profiling and pattern recognition +- **Real-Time Monitoring**: Continuous transaction analysis with instant alerts +- **Risk Scoring**: Multi-factor risk assessment with trending analysis +- **Behavioral Profiling**: Learns user spending patterns and detects deviations +- **Blacklist Management**: Maintains blocked entities (merchants, IPs, devices) +- **Automated Prevention**: Blocks suspicious transactions before they complete +- **Investigation Tools**: Comprehensive event tracking and forensic analysis +- **Appeal System**: User-friendly dispute resolution process + +## Models + +### 1. AnomalyRule +Defines detection rules for identifying suspicious activities. + +**Schema:** +```javascript +{ + name: String, + description: String, + type: 'threshold' | 'pattern' | 'velocity' | 'geo' | 'behavioral', + conditions: Map, + severity: 'low' | 'medium' | 'high' | 'critical', + action: 'alert' | 'block' | 'review', + isActive: Boolean, + priority: Number, + detections: { + total: Number, + truePositives: Number, + falsePositives: Number, + pending: Number + }, + accuracy: Number, + lastTriggered: Date, + cooldownPeriod: Number, + notificationChannels: [String], + tags: [String], + createdBy: ObjectId +} +``` + +**Rule Types:** + +1. **Threshold Rules**: Simple value comparisons + ```javascript + conditions: { + field: 'amount', + operator: '>', + value: 1000 + } + ``` + +2. **Velocity Rules**: Transaction frequency checks + ```javascript + conditions: { + transactions: 5, + timeWindow: 3600, // seconds + maxCount: 10 + } + ``` + +3. **Pattern Rules**: Sequence detection + ```javascript + conditions: { + sequence: ['high_value', 'foreign', 'new_merchant'], + window: 86400, + threshold: 3 + } + ``` + +4. **Geo Rules**: Location-based detection + ```javascript + conditions: { + blockedCountries: ['XX', 'YY'], + allowedCountries: ['US', 'CA'], + distanceThreshold: 1000 // km + } + ``` + +5. **Behavioral Rules**: User pattern deviation + ```javascript + conditions: { + deviationThreshold: 2.5, + profileFields: ['amount', 'category', 'time'] + } + ``` + +### 2. AnomalyEvent +Records detected anomalies and investigation details. + +**Schema:** +```javascript +{ + userId: ObjectId, + transactionId: ObjectId, + ruleId: ObjectId, + type: 'unusual_amount' | 'suspicious_velocity' | 'abnormal_pattern' | 'geo_anomaly' | 'behavioral_deviation' | 'duplicate_transaction' | 'merchant_anomaly' | 'time_anomaly' | 'category_anomaly' | 'device_mismatch' | 'multiple_failures' | 'compromised_credentials', + score: Number (0-100), + severity: 'low' | 'medium' | 'high' | 'critical', + details: { + description: String, + triggeredConditions: [String], + expectedValue: Mixed, + actualValue: Mixed, + deviationPercentage: Number, + contributingFactors: [{ + factor: String, + weight: Number, + value: Mixed + }], + metadata: Map + }, + status: 'pending' | 'confirmed_fraud' | 'false_positive' | 'resolved' | 'escalated', + reviewedBy: ObjectId, + reviewedAt: Date, + reviewNotes: String, + actionsTaken: [{ + action: 'alert_sent' | 'transaction_blocked' | 'account_locked' | 'review_requested' | 'user_notified' | 'escalated' | 'auto_resolved', + timestamp: Date, + performedBy: ObjectId, + details: String + }], + context: { + transactionAmount: Number, + transactionCategory: String, + merchant: String, + location: Object, + device: Object, + timestamp: Date, + userBehaviorScore: Number + }, + investigation: { + assignedTo: ObjectId, + startedAt: Date, + completedAt: Date, + findings: String, + priority: 'low' | 'medium' | 'high' | 'urgent' + }, + financialImpact: { + potentialLoss: Number, + actualLoss: Number, + recovered: Number, + preventedLoss: Number + } +} +``` + +### 3. UserBehaviorProfile +Tracks user spending patterns and behavioral baselines. + +**Schema:** +```javascript +{ + userId: ObjectId, + avgDailySpend: Number, + avgTransactionSize: Number, + medianTransactionSize: Number, + maxTransactionSize: Number, + transactionSizeStdDev: Number, + typicalCategories: [{ + category: String, + frequency: Number, + avgAmount: Number, + percentage: Number + }], + typicalMerchants: [{ + merchant: String, + frequency: Number, + avgAmount: Number, + isTrusted: Boolean + }], + activeHours: [{ + hour: Number (0-23), + transactionCount: Number, + avgAmount: Number + }], + activeDaysOfWeek: [{ + day: Number (0-6), + transactionCount: Number, + avgAmount: Number + }], + typicalLocations: [{ + country: String, + city: String, + coordinates: { lat: Number, lng: Number }, + frequency: Number, + radius: Number + }], + deviceFingerprints: [{ + fingerprint: String, + deviceType: 'mobile' | 'tablet' | 'desktop' | 'other', + transactionCount: Number, + isTrusted: Boolean, + ipAddresses: [{ ip: String, lastSeen: Date }] + }], + velocityProfile: { + avgTransactionsPerDay: Number, + maxTransactionsPerDay: Number, + avgTransactionsPerHour: Number + }, + statistics: { + totalTransactions: Number, + totalSpend: Number, + accountAgeInDays: Number, + dataQuality: 'low' | 'medium' | 'high' + } +} +``` + +### 4. RiskScore +Calculates and tracks overall user risk levels. + +**Schema:** +```javascript +{ + userId: ObjectId, + overallScore: Number (0-100), + riskLevel: 'minimal' | 'low' | 'medium' | 'high' | 'critical', + factors: [{ + name: 'transaction_velocity' | 'high_value_transactions' | 'unusual_patterns' | 'geographic_risk' | 'behavioral_deviation' | 'device_anomalies' | 'merchant_risk' | 'account_age' | 'verification_status' | 'historical_fraud' | 'failed_transactions' | 'suspicious_activities' | 'chargebacks', + score: Number (0-100), + weight: Number (0-1), + description: String, + severity: 'low' | 'medium' | 'high' | 'critical', + evidence: Map + }], + scoreHistory: [{ + score: Number, + timestamp: Date, + triggerEvent: String, + changedFactors: [String] + }], + trend: 'increasing' | 'stable' | 'decreasing', + trendPercentage: Number, + thresholds: { + warning: Number, + critical: Number + }, + alerts: [{ + level: 'warning' | 'critical', + triggeredAt: Date, + acknowledged: Boolean + }], + mitigationActions: [{ + action: 'increase_monitoring' | 'require_verification' | 'limit_transactions' | 'manual_review' | 'account_restriction' | 'enhanced_authentication' | 'contact_user', + status: 'pending' | 'in_progress' | 'completed' | 'failed', + assignedTo: ObjectId + }] +} +``` + +### 5. BlockedEntity +Manages blacklist of blocked merchants, IPs, devices, and cards. + +**Schema:** +```javascript +{ + type: 'merchant' | 'ip' | 'device' | 'card' | 'email' | 'phone' | 'country' | 'user', + value: String, + hashedValue: String, + reason: 'confirmed_fraud' | 'repeated_chargebacks' | 'suspicious_activity' | 'identity_theft' | 'account_takeover' | 'multiple_violations' | 'high_risk_region' | 'known_fraudster', + severity: 'low' | 'medium' | 'high' | 'critical', + details: { + description: String, + associatedTransactions: [ObjectId], + associatedUsers: [ObjectId], + associatedEvents: [ObjectId], + evidence: Map + }, + scope: 'global' | 'platform' | 'user_specific', + userId: ObjectId, + expiresAt: Date, + isPermanent: Boolean, + isActive: Boolean, + addedBy: ObjectId, + hits: { + total: Number, + last30Days: Number, + lastHitAt: Date + }, + preventedTransactions: Number, + preventedLoss: Number, + appeals: [{ + submittedBy: ObjectId, + reason: String, + status: 'pending' | 'approved' | 'rejected', + reviewedBy: ObjectId + }], + attributes: { + merchantCategory: String, + ipRange: String, + deviceType: String, + cardType: String, + countryCode: String + } +} +``` + +## API Examples + +### Create Anomaly Rule + +```javascript +const AnomalyRule = require('./models/AnomalyRule'); + +// Create threshold rule +const rule = await AnomalyRule.create({ + name: 'High Value Transaction Alert', + description: 'Alert on transactions over $1000', + type: 'threshold', + conditions: new Map([ + ['field', 'amount'], + ['operator', '>'], + ['value', 1000] + ]), + severity: 'high', + action: 'review', + priority: 80, + cooldownPeriod: 60, + notificationChannels: ['email', 'push'], + createdBy: userId +}); + +// Create velocity rule +const velocityRule = await AnomalyRule.create({ + name: 'Rapid Transaction Detection', + type: 'velocity', + conditions: new Map([ + ['timeWindow', 3600], + ['maxCount', 5], + ['threshold', 80] + ]), + severity: 'critical', + action: 'block', + createdBy: userId +}); +``` + +### Evaluate Transaction + +```javascript +const UserBehaviorProfile = require('./models/UserBehaviorProfile'); +const AnomalyRule = require('./models/AnomalyRule'); +const AnomalyEvent = require('./models/AnomalyEvent'); + +// Get user profile +const profile = await UserBehaviorProfile.getOrCreateProfile(userId); + +// Calculate anomaly score +const transaction = { + amount: 1500, + category: 'Electronics', + merchant: 'NewStore', + date: new Date(), + location: { country: 'US', city: 'New York' }, + device: { fingerprint: 'abc123', type: 'mobile' } +}; + +const anomalyScore = profile.calculateAnomalyScore(transaction); + +// Get active rules +const rules = await AnomalyRule.getActiveRules(); + +// Evaluate rules +for (const rule of rules) { + const triggered = rule.evaluate(transaction, profile); + + if (triggered) { + // Create anomaly event + const event = await AnomalyEvent.create({ + userId, + transactionId: transaction._id, + ruleId: rule._id, + type: 'unusual_amount', + score: anomalyScore, + severity: rule.severity, + status: 'pending', + details: { + description: `Transaction triggered rule: ${rule.name}`, + actualValue: transaction.amount, + expectedValue: profile.avgTransactionSize + }, + context: { + transactionAmount: transaction.amount, + transactionCategory: transaction.category, + merchant: transaction.merchant + } + }); + + // Record detection + await rule.recordDetection(null); // null = pending review + + // Take action based on rule + if (rule.action === 'block') { + // Block transaction + await event.recordAction('transaction_blocked', systemUserId); + } else if (rule.action === 'alert') { + // Send alert + await event.sendNotification('email'); + } + } +} +``` + +### Calculate Risk Score + +```javascript +const RiskScore = require('./models/RiskScore'); + +// Create risk score +const riskScore = new RiskScore({ + userId, + overallScore: 0 +}); + +// Add risk factors +riskScore.updateFactor( + 'transaction_velocity', + 85, + 0.3, + 'Unusually high transaction frequency', + { count: 10, period: '1 hour', typical: 2 } +); + +riskScore.updateFactor( + 'high_value_transactions', + 70, + 0.25, + 'Multiple high-value transactions', + { amount: 5000, avgAmount: 150 } +); + +riskScore.updateFactor( + 'geographic_risk', + 60, + 0.15, + 'Transactions from unusual location', + { location: 'Foreign Country', typical: 'US' } +); + +// Calculate overall score +riskScore.calculateOverallScore(); + +// Check for alerts +riskScore.checkAlerts(); + +// Get recommended actions +const actions = riskScore.getRecommendedActions(); + +// Add mitigation actions +for (const action of actions) { + riskScore.addMitigationAction(action, reviewerId); +} + +await riskScore.save(); +``` + +### Block Entity + +```javascript +const BlockedEntity = require('./models/BlockedEntity'); + +// Block a merchant +const blockedMerchant = await BlockedEntity.create({ + type: 'merchant', + value: 'SuspiciousMerchant Inc', + reason: 'confirmed_fraud', + severity: 'high', + details: { + description: 'Multiple fraud reports from users', + associatedTransactions: [txId1, txId2], + associatedEvents: [eventId1, eventId2] + }, + scope: 'platform', + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days + addedBy: adminId, + attributes: { + merchantCategory: 'Electronics', + merchantCountry: 'XX' + } +}); + +// Block an IP address +const blockedIP = await BlockedEntity.create({ + type: 'ip', + value: '192.168.1.100', + reason: 'suspicious_activity', + severity: 'medium', + scope: 'global', + isPermanent: true, + addedBy: adminId, + attributes: { + ipRange: '192.168.1.0/24', + isp: 'Suspicious ISP' + } +}); + +// Check if entity is blocked +const { blocked, block } = await BlockedEntity.isBlocked('merchant', 'SuspiciousMerchant Inc'); + +if (blocked) { + console.log('Transaction blocked:', block.reason); + // Prevent transaction +} +``` + +### Handle Anomaly Events + +```javascript +// Get pending events +const pendingEvents = await AnomalyEvent.getPendingEvents(50); + +// Review event +const event = await AnomalyEvent.findById(eventId); + +// Confirm as fraud +await event.confirmFraud(reviewerId, 'Verified fraudulent transaction through investigation'); + +// Or mark as false positive +await event.markFalsePositive(reviewerId, 'Legitimate transaction, user confirmed'); + +// Escalate event +await event.escalate(seniorReviewerId, 'Requires senior review due to high value'); + +// Update financial impact +await event.updateFinancialImpact({ + potentialLoss: 5000, + actualLoss: 0, + preventedLoss: 5000 +}); + +// Get high-risk events +const highRiskEvents = await AnomalyEvent.getHighRiskEvents(80); +``` + +### Profile Management + +```javascript +const UserBehaviorProfile = require('./models/UserBehaviorProfile'); + +// Update profile with new transaction +const profile = await UserBehaviorProfile.getOrCreateProfile(userId); +await profile.updateWithTransaction({ + amount: 150, + category: 'Groceries', + merchant: 'SuperMart', + date: new Date(), + location: { country: 'US', city: 'New York' }, + device: { fingerprint: 'abc123', type: 'mobile', ipAddress: '192.168.1.1' } +}); + +// Full recalculation from history +const transactions = await Expense.find({ userId }); +await profile.recalculateFromHistory(transactions); + +// Check profile maturity +if (profile.isMature) { + console.log('Profile is ready for anomaly detection'); + console.log('Completeness:', profile.completeness, '%'); +} + +// Get profiles needing update +const staleProfiles = await UserBehaviorProfile.getProfilesNeedingUpdate(); +``` + +## Detection Algorithms + +### 1. Threshold-Based Detection +Simple rule-based detection for known patterns: +- Transaction amount exceeds limit +- Velocity exceeds normal rate +- Geographic distance from home + +### 2. Statistical Detection +Uses statistical methods: +- Standard deviation analysis +- Z-score calculation +- Moving averages + +### 3. Behavioral Analysis +Learns user patterns: +- Category preferences +- Merchant habits +- Time patterns +- Location patterns +- Device patterns + +### 4. Machine Learning (Future Enhancement) +- Neural networks for complex pattern recognition +- Ensemble models combining multiple algorithms +- Continuous learning from feedback + +## Risk Scoring System + +**Score Calculation:** +``` +Overall Score = Σ (Factor Score × Factor Weight) +``` + +**Risk Levels:** +- 0-19: Minimal risk +- 20-39: Low risk +- 40-64: Medium risk +- 65-79: High risk +- 80-100: Critical risk + +**Factor Weights:** +- Transaction Velocity: 0.30 +- High Value Transactions: 0.25 +- Geographic Risk: 0.20 +- Behavioral Deviation: 0.15 +- Device Anomalies: 0.10 + +## Best Practices + +### Rule Creation +1. Start with conservative thresholds +2. Monitor false positive rates +3. Adjust based on effectiveness +4. Use cooldown periods to prevent alert fatigue +5. Tag rules for easy organization + +### Profile Building +1. Require minimum 20 transactions for reliability +2. Update profiles regularly (daily recommended) +3. Handle seasonal patterns +4. Account for life changes (moving, new job) +5. Respect privacy and data retention policies + +### Investigation Workflow +1. Review high-severity events first +2. Check user history and context +3. Look for related events +4. Contact user when necessary +5. Document findings thoroughly +6. Update rules based on learnings + +### Block Management +1. Use temporary blocks initially +2. Require review before permanent blocks +3. Document evidence clearly +4. Allow appeals process +5. Review block effectiveness regularly +6. Clean up expired blocks + +## Performance Considerations + +- **Indexing**: All models have optimized indexes for common queries +- **Caching**: Consider caching user profiles and active rules +- **Async Processing**: Run detection algorithms asynchronously +- **Batch Updates**: Update profiles in batches during off-peak hours +- **Data Retention**: Archive old events and score history + +## Security & Privacy + +- Hash sensitive data (card numbers, emails) +- Implement role-based access control +- Audit all manual reviews and actions +- Comply with data protection regulations (GDPR, CCPA) +- Provide user transparency and appeal rights +- Secure API endpoints with authentication + +## Monitoring & Metrics + +Track these key metrics: +- Rule accuracy rates +- False positive/negative rates +- Average resolution time +- Prevented loss amount +- User appeal success rate +- System performance metrics + +## Integration Points + +```javascript +// Express middleware example +const anomalyDetectionMiddleware = async (req, res, next) => { + const { userId, transaction } = req.body; + + // Check blocked entities + const merchantCheck = await BlockedEntity.isBlocked('merchant', transaction.merchant, userId); + if (merchantCheck.blocked) { + return res.status(403).json({ error: 'Merchant is blocked', reason: merchantCheck.block.reason }); + } + + // Get user profile and risk score + const profile = await UserBehaviorProfile.getOrCreateProfile(userId); + const riskScore = await RiskScore.getLatestForUser(userId); + + // High-risk users require additional verification + if (riskScore && riskScore.isHighRisk) { + req.requireAdditionalVerification = true; + } + + // Evaluate anomaly rules + const rules = await AnomalyRule.getActiveRules(); + for (const rule of rules) { + if (rule.evaluate(transaction, profile)) { + // Create event and take action + const event = await AnomalyEvent.create({ + userId, + transactionId: transaction._id, + ruleId: rule._id, + type: determineAnomalyType(rule), + score: profile.calculateAnomalyScore(transaction), + severity: rule.severity, + status: 'pending' + }); + + if (rule.action === 'block') { + return res.status(403).json({ error: 'Transaction blocked due to suspicious activity' }); + } + } + } + + next(); +}; +``` + +## Future Enhancements + +1. **Advanced ML Models**: Implement neural networks and deep learning +2. **Graph Analysis**: Detect fraud rings and network patterns +3. **External Data Integration**: Credit bureaus, fraud databases +4. **Biometric Verification**: Face recognition, fingerprint +5. **Real-Time Collaboration**: Team investigation tools +6. **Predictive Analytics**: Forecast fraud trends +7. **A/B Testing**: Test rule effectiveness +8. **Automated Remediation**: Self-healing systems + +## Support + +For issues or questions: +- Review rule effectiveness regularly +- Monitor false positive rates +- Adjust thresholds based on user feedback +- Keep rules documentation updated +- Train team on investigation procedures diff --git a/models/AnomalyEvent.js b/models/AnomalyEvent.js new file mode 100644 index 00000000..cf312275 --- /dev/null +++ b/models/AnomalyEvent.js @@ -0,0 +1,630 @@ +const mongoose = require('mongoose'); + +/** + * AnomalyEvent Schema + * Records detected anomalies and fraud attempts + */ +const anomalyEventSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'User ID is required'], + index: true + }, + transactionId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Expense', + required: [true, 'Transaction ID is required'], + index: true + }, + ruleId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'AnomalyRule', + required: [true, 'Rule ID is required'], + index: true + }, + type: { + type: String, + required: [true, 'Anomaly type is required'], + enum: { + values: [ + 'unusual_amount', + 'suspicious_velocity', + 'abnormal_pattern', + 'geo_anomaly', + 'behavioral_deviation', + 'duplicate_transaction', + 'merchant_anomaly', + 'time_anomaly', + 'category_anomaly', + 'device_mismatch', + 'multiple_failures', + 'compromised_credentials' + ], + message: '{VALUE} is not a valid anomaly type' + } + }, + score: { + type: Number, + required: [true, 'Anomaly score is required'], + min: [0, 'Score must be between 0 and 100'], + max: [100, 'Score must be between 0 and 100'], + index: true + }, + severity: { + type: String, + enum: ['low', 'medium', 'high', 'critical'], + required: true + }, + details: { + // Flexible object for anomaly-specific information + description: String, + triggeredConditions: [String], + expectedValue: mongoose.Schema.Types.Mixed, + actualValue: mongoose.Schema.Types.Mixed, + deviationPercentage: Number, + contributingFactors: [{ + factor: String, + weight: Number, + value: mongoose.Schema.Types.Mixed + }], + metadata: { + type: Map, + of: mongoose.Schema.Types.Mixed + } + }, + status: { + type: String, + required: [true, 'Status is required'], + enum: { + values: ['pending', 'confirmed_fraud', 'false_positive', 'resolved', 'escalated'], + message: '{VALUE} is not a valid status' + }, + default: 'pending', + index: true + }, + reviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + reviewedAt: { + type: Date + }, + reviewNotes: { + type: String, + maxlength: [1000, 'Review notes cannot exceed 1000 characters'] + }, + // Actions taken + actionsTaken: [{ + action: { + type: String, + enum: ['alert_sent', 'transaction_blocked', 'account_locked', 'review_requested', 'user_notified', 'escalated', 'auto_resolved'] + }, + timestamp: { + type: Date, + default: Date.now + }, + performedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + details: String + }], + // Evidence and context + context: { + transactionAmount: Number, + transactionCategory: String, + merchant: String, + location: { + country: String, + city: String, + coordinates: { + lat: Number, + lng: Number + } + }, + device: { + type: String, + fingerprint: String, + ipAddress: String + }, + timestamp: Date, + userBehaviorScore: Number, + recentTransactions: Number, + accountAge: Number + }, + // Investigation + investigation: { + assignedTo: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + startedAt: Date, + completedAt: Date, + findings: String, + priority: { + type: String, + enum: ['low', 'medium', 'high', 'urgent'], + default: 'medium' + } + }, + // User response + userResponse: { + acknowledged: { + type: Boolean, + default: false + }, + acknowledgedAt: Date, + disputeRaised: { + type: Boolean, + default: false + }, + disputeDetails: String, + userConfirmedFraud: Boolean, + responseTimestamp: Date + }, + // Related events + relatedEvents: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'AnomalyEvent' + }], + // Financial impact + financialImpact: { + potentialLoss: Number, + actualLoss: Number, + recovered: Number, + preventedLoss: Number + }, + // Auto-resolution + autoResolved: { + type: Boolean, + default: false + }, + autoResolvedAt: { + type: Date + }, + autoResolveReason: { + type: String + }, + // Notifications sent + notificationsSent: [{ + channel: { + type: String, + enum: ['email', 'sms', 'push', 'webhook'] + }, + sentAt: Date, + status: { + type: String, + enum: ['sent', 'delivered', 'failed', 'bounced'] + } + }], + // ML confidence + mlConfidence: { + type: Number, + min: 0, + max: 100 + }, + // False positive feedback + feedbackProvided: { + type: Boolean, + default: false + }, + feedbackScore: { + type: Number, + min: 1, + max: 5 + } +}, { + timestamps: true +}); + +// Indexes +anomalyEventSchema.index({ userId: 1, createdAt: -1 }); +anomalyEventSchema.index({ status: 1, createdAt: -1 }); +anomalyEventSchema.index({ score: -1, status: 1 }); +anomalyEventSchema.index({ type: 1, status: 1 }); +anomalyEventSchema.index({ severity: 1, status: 1 }); +anomalyEventSchema.index({ 'investigation.assignedTo': 1, status: 1 }); +anomalyEventSchema.index({ createdAt: -1 }); + +// Compound indexes +anomalyEventSchema.index({ userId: 1, status: 1, createdAt: -1 }); +anomalyEventSchema.index({ ruleId: 1, status: 1 }); +anomalyEventSchema.index({ transactionId: 1 }); + +// Virtual for risk level +anomalyEventSchema.virtual('riskLevel').get(function() { + if (this.score >= 80) return 'critical'; + if (this.score >= 60) return 'high'; + if (this.score >= 40) return 'medium'; + return 'low'; +}); + +// Virtual for is pending +anomalyEventSchema.virtual('isPending').get(function() { + return this.status === 'pending'; +}); + +// Virtual for is resolved +anomalyEventSchema.virtual('isResolved').get(function() { + return ['confirmed_fraud', 'false_positive', 'resolved'].includes(this.status); +}); + +// Virtual for resolution time (in hours) +anomalyEventSchema.virtual('resolutionTime').get(function() { + if (!this.reviewedAt) return null; + const diff = this.reviewedAt - this.createdAt; + return diff / (1000 * 60 * 60); // Convert to hours +}); + +// Methods + +/** + * Mark as confirmed fraud + */ +anomalyEventSchema.methods.confirmFraud = async function(reviewerId, notes) { + this.status = 'confirmed_fraud'; + this.reviewedBy = reviewerId; + this.reviewedAt = new Date(); + this.reviewNotes = notes; + + // Record action + this.actionsTaken.push({ + action: 'review_requested', + performedBy: reviewerId, + details: 'Confirmed as fraud' + }); + + // Update rule effectiveness + const AnomalyRule = mongoose.model('AnomalyRule'); + const rule = await AnomalyRule.findById(this.ruleId); + if (rule) { + await rule.recordDetection(true); + } + + return await this.save(); +}; + +/** + * Mark as false positive + */ +anomalyEventSchema.methods.markFalsePositive = async function(reviewerId, notes) { + this.status = 'false_positive'; + this.reviewedBy = reviewerId; + this.reviewedAt = new Date(); + this.reviewNotes = notes; + + // Record action + this.actionsTaken.push({ + action: 'auto_resolved', + performedBy: reviewerId, + details: 'Marked as false positive' + }); + + // Update rule effectiveness + const AnomalyRule = mongoose.model('AnomalyRule'); + const rule = await AnomalyRule.findById(this.ruleId); + if (rule) { + await rule.recordDetection(false); + } + + return await this.save(); +}; + +/** + * Escalate event + */ +anomalyEventSchema.methods.escalate = async function(assignTo, reason) { + this.status = 'escalated'; + + if (!this.investigation) { + this.investigation = {}; + } + + this.investigation.assignedTo = assignTo; + this.investigation.startedAt = new Date(); + this.investigation.priority = 'urgent'; + + this.actionsTaken.push({ + action: 'escalated', + performedBy: assignTo, + details: reason + }); + + return await this.save(); +}; + +/** + * Record action taken + */ +anomalyEventSchema.methods.recordAction = async function(action, performedBy, details = null) { + this.actionsTaken.push({ + action, + performedBy, + details, + timestamp: new Date() + }); + + return await this.save(); +}; + +/** + * Auto-resolve based on criteria + */ +anomalyEventSchema.methods.autoResolve = async function(reason) { + this.status = 'resolved'; + this.autoResolved = true; + this.autoResolvedAt = new Date(); + this.autoResolveReason = reason; + + this.actionsTaken.push({ + action: 'auto_resolved', + details: reason, + timestamp: new Date() + }); + + return await this.save(); +}; + +/** + * Send notification + */ +anomalyEventSchema.methods.sendNotification = async function(channel) { + this.notificationsSent.push({ + channel, + sentAt: new Date(), + status: 'sent' + }); + + return await this.save(); +}; + +/** + * Update notification status + */ +anomalyEventSchema.methods.updateNotificationStatus = async function(channel, status) { + const notification = this.notificationsSent.find(n => + n.channel === channel && n.status === 'sent' + ); + + if (notification) { + notification.status = status; + return await this.save(); + } + + return this; +}; + +/** + * Link related event + */ +anomalyEventSchema.methods.linkRelatedEvent = async function(eventId) { + if (!this.relatedEvents.includes(eventId)) { + this.relatedEvents.push(eventId); + return await this.save(); + } + return this; +}; + +/** + * Calculate and update financial impact + */ +anomalyEventSchema.methods.updateFinancialImpact = async function(impact) { + this.financialImpact = { + ...this.financialImpact, + ...impact + }; + + return await this.save(); +}; + +/** + * Get age in hours + */ +anomalyEventSchema.methods.getAgeInHours = function() { + const now = new Date(); + const diff = now - this.createdAt; + return diff / (1000 * 60 * 60); +}; + +/** + * Check if stale (unresolved for too long) + */ +anomalyEventSchema.methods.isStale = function(thresholdHours = 48) { + if (this.isResolved) return false; + return this.getAgeInHours() > thresholdHours; +}; + +// Static methods + +/** + * Get events by user + */ +anomalyEventSchema.statics.getUserEvents = async function(userId, status = null) { + const query = { userId }; + if (status) query.status = status; + + return await this.find(query) + .sort({ createdAt: -1 }) + .populate('ruleId', 'name type severity') + .populate('transactionId', 'amount category merchant') + .lean(); +}; + +/** + * Get pending events + */ +anomalyEventSchema.statics.getPendingEvents = async function(limit = 50) { + return await this.find({ status: 'pending' }) + .sort({ score: -1, createdAt: 1 }) + .limit(limit) + .populate('userId', 'name email') + .populate('ruleId', 'name type severity') + .lean(); +}; + +/** + * Get high-risk events + */ +anomalyEventSchema.statics.getHighRiskEvents = async function(scoreThreshold = 70) { + return await this.find({ + score: { $gte: scoreThreshold }, + status: 'pending' + }) + .sort({ score: -1 }) + .populate('userId', 'name email') + .populate('transactionId', 'amount category merchant') + .lean(); +}; + +/** + * Get events by rule + */ +anomalyEventSchema.statics.getEventsByRule = async function(ruleId, startDate = null, endDate = null) { + const query = { ruleId }; + + if (startDate || endDate) { + query.createdAt = {}; + if (startDate) query.createdAt.$gte = startDate; + if (endDate) query.createdAt.$lte = endDate; + } + + return await this.find(query) + .sort({ createdAt: -1 }) + .lean(); +}; + +/** + * Get events requiring attention + */ +anomalyEventSchema.statics.getEventsRequiringAttention = async function() { + const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000); + + return await this.find({ + status: { $in: ['pending', 'escalated'] }, + $or: [ + { score: { $gte: 70 } }, + { severity: { $in: ['high', 'critical'] } }, + { createdAt: { $lte: fortyEightHoursAgo } } + ] + }) + .sort({ score: -1, createdAt: 1 }) + .populate('userId', 'name email') + .populate('investigation.assignedTo', 'name email') + .lean(); +}; + +/** + * Get statistics + */ +anomalyEventSchema.statics.getStatistics = async function(startDate = null, endDate = null) { + const matchQuery = {}; + + if (startDate || endDate) { + matchQuery.createdAt = {}; + if (startDate) matchQuery.createdAt.$gte = startDate; + if (endDate) matchQuery.createdAt.$lte = endDate; + } + + const stats = await this.aggregate([ + { $match: matchQuery }, + { + $group: { + _id: null, + totalEvents: { $sum: 1 }, + pending: { + $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } + }, + confirmedFraud: { + $sum: { $cond: [{ $eq: ['$status', 'confirmed_fraud'] }, 1, 0] } + }, + falsePositives: { + $sum: { $cond: [{ $eq: ['$status', 'false_positive'] }, 1, 0] } + }, + resolved: { + $sum: { $cond: [{ $eq: ['$status', 'resolved'] }, 1, 0] } + }, + avgScore: { $avg: '$score' }, + totalPotentialLoss: { $sum: '$financialImpact.potentialLoss' }, + totalActualLoss: { $sum: '$financialImpact.actualLoss' }, + totalPreventedLoss: { $sum: '$financialImpact.preventedLoss' } + } + } + ]); + + if (stats.length === 0) { + return { + totalEvents: 0, + pending: 0, + confirmedFraud: 0, + falsePositives: 0, + resolved: 0, + avgScore: 0, + accuracy: 0, + totalPotentialLoss: 0, + totalActualLoss: 0, + totalPreventedLoss: 0 + }; + } + + const result = stats[0]; + const reviewed = result.confirmedFraud + result.falsePositives; + result.accuracy = reviewed > 0 ? (result.confirmedFraud / reviewed) * 100 : 0; + + return result; +}; + +/** + * Get events by type statistics + */ +anomalyEventSchema.statics.getTypeStatistics = async function() { + return await this.aggregate([ + { + $group: { + _id: '$type', + count: { $sum: 1 }, + avgScore: { $avg: '$score' }, + confirmed: { + $sum: { $cond: [{ $eq: ['$status', 'confirmed_fraud'] }, 1, 0] } + }, + falsePositives: { + $sum: { $cond: [{ $eq: ['$status', 'false_positive'] }, 1, 0] } + } + } + }, + { $sort: { count: -1 } } + ]); +}; + +/** + * Auto-resolve old low-risk events + */ +anomalyEventSchema.statics.autoResolveOldEvents = async function(daysOld = 30, maxScore = 40) { + const cutoffDate = new Date(Date.now() - daysOld * 24 * 60 * 60 * 1000); + + const events = await this.find({ + status: 'pending', + score: { $lte: maxScore }, + createdAt: { $lte: cutoffDate } + }); + + const results = { + resolved: 0, + failed: 0 + }; + + for (const event of events) { + try { + await event.autoResolve(`Auto-resolved: Low risk event older than ${daysOld} days`); + results.resolved++; + } catch (error) { + results.failed++; + } + } + + return results; +}; + +const AnomalyEvent = mongoose.model('AnomalyEvent', anomalyEventSchema); + +module.exports = AnomalyEvent; diff --git a/models/AnomalyRule.js b/models/AnomalyRule.js new file mode 100644 index 00000000..e190e795 --- /dev/null +++ b/models/AnomalyRule.js @@ -0,0 +1,453 @@ +const mongoose = require('mongoose'); + +/** + * AnomalyRule Schema + * Defines rules for detecting suspicious transactions and unusual patterns + */ +const anomalyRuleSchema = new mongoose.Schema({ + name: { + type: String, + required: [true, 'Rule name is required'], + trim: true, + maxlength: [100, 'Name cannot exceed 100 characters'] + }, + description: { + type: String, + trim: true, + maxlength: [500, 'Description cannot exceed 500 characters'] + }, + type: { + type: String, + required: [true, 'Rule type is required'], + enum: { + values: ['threshold', 'pattern', 'velocity', 'geo', 'behavioral'], + message: '{VALUE} is not a valid rule type' + } + }, + conditions: { + // Flexible JSON structure for different rule types + // threshold: { field, operator, value } + // pattern: { sequence, window, threshold } + // velocity: { transactions, timeWindow, maxCount } + // geo: { allowedCountries, blockedCountries, distanceThreshold } + // behavioral: { deviationThreshold, profileFields } + type: Map, + of: mongoose.Schema.Types.Mixed, + required: [true, 'Rule conditions are required'], + validate: { + validator: function(conditions) { + return conditions && conditions.size > 0; + }, + message: 'At least one condition must be specified' + } + }, + severity: { + type: String, + required: [true, 'Severity level is required'], + enum: { + values: ['low', 'medium', 'high', 'critical'], + message: '{VALUE} is not a valid severity level' + }, + default: 'medium' + }, + action: { + type: String, + required: [true, 'Action is required'], + enum: { + values: ['alert', 'block', 'review'], + message: '{VALUE} is not a valid action' + }, + default: 'alert' + }, + isActive: { + type: Boolean, + default: true, + index: true + }, + priority: { + type: Number, + default: 50, + min: [1, 'Priority must be at least 1'], + max: [100, 'Priority cannot exceed 100'] + }, + // Rule effectiveness metrics + detections: { + total: { type: Number, default: 0 }, + truePositives: { type: Number, default: 0 }, + falsePositives: { type: Number, default: 0 }, + pending: { type: Number, default: 0 } + }, + accuracy: { + type: Number, + default: 0, + min: 0, + max: 100 + }, + lastTriggered: { + type: Date + }, + cooldownPeriod: { + // Minimum time between triggers for same user (in minutes) + type: Number, + default: 0, + min: [0, 'Cooldown period cannot be negative'] + }, + notificationChannels: [{ + type: String, + enum: ['email', 'sms', 'push', 'webhook'] + }], + tags: [{ + type: String, + trim: true + }], + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'Creator is required'] + }, + lastModifiedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } +}, { + timestamps: true +}); + +// Indexes +anomalyRuleSchema.index({ type: 1, isActive: 1 }); +anomalyRuleSchema.index({ severity: 1, isActive: 1 }); +anomalyRuleSchema.index({ createdBy: 1 }); +anomalyRuleSchema.index({ tags: 1 }); +anomalyRuleSchema.index({ accuracy: -1 }); + +// Virtual for effectiveness rate +anomalyRuleSchema.virtual('effectivenessRate').get(function() { + const total = this.detections.truePositives + this.detections.falsePositives; + if (total === 0) return 0; + return (this.detections.truePositives / total) * 100; +}); + +// Virtual for false positive rate +anomalyRuleSchema.virtual('falsePositiveRate').get(function() { + const total = this.detections.truePositives + this.detections.falsePositives; + if (total === 0) return 0; + return (this.detections.falsePositives / total) * 100; +}); + +// Methods + +/** + * Record detection result + */ +anomalyRuleSchema.methods.recordDetection = async function(isFraud) { + this.detections.total += 1; + this.lastTriggered = new Date(); + + if (isFraud === true) { + this.detections.truePositives += 1; + } else if (isFraud === false) { + this.detections.falsePositives += 1; + } else { + this.detections.pending += 1; + } + + // Update accuracy + const reviewed = this.detections.truePositives + this.detections.falsePositives; + if (reviewed > 0) { + this.accuracy = (this.detections.truePositives / reviewed) * 100; + } + + return await this.save(); +}; + +/** + * Update detection status from pending to confirmed + */ +anomalyRuleSchema.methods.updateDetectionStatus = async function(isFraud) { + if (this.detections.pending > 0) { + this.detections.pending -= 1; + + if (isFraud) { + this.detections.truePositives += 1; + } else { + this.detections.falsePositives += 1; + } + + // Recalculate accuracy + const reviewed = this.detections.truePositives + this.detections.falsePositives; + if (reviewed > 0) { + this.accuracy = (this.detections.truePositives / reviewed) * 100; + } + + return await this.save(); + } + return this; +}; + +/** + * Check if rule is in cooldown for a specific user + */ +anomalyRuleSchema.methods.isInCooldown = function(userId) { + if (!this.cooldownPeriod || this.cooldownPeriod === 0) return false; + if (!this.lastTriggered) return false; + + const cooldownMs = this.cooldownPeriod * 60 * 1000; + const timeSinceLastTrigger = Date.now() - this.lastTriggered.getTime(); + + return timeSinceLastTrigger < cooldownMs; +}; + +/** + * Evaluate if conditions match transaction data + */ +anomalyRuleSchema.methods.evaluate = function(transactionData, userProfile) { + if (!this.isActive) return false; + + switch (this.type) { + case 'threshold': + return this.evaluateThreshold(transactionData); + case 'velocity': + return this.evaluateVelocity(transactionData); + case 'pattern': + return this.evaluatePattern(transactionData); + case 'geo': + return this.evaluateGeo(transactionData); + case 'behavioral': + return this.evaluateBehavioral(transactionData, userProfile); + default: + return false; + } +}; + +/** + * Evaluate threshold conditions + */ +anomalyRuleSchema.methods.evaluateThreshold = function(data) { + const field = this.conditions.get('field'); + const operator = this.conditions.get('operator'); + const value = this.conditions.get('value'); + + const actualValue = data[field]; + if (actualValue === undefined) return false; + + switch (operator) { + case '>': return actualValue > value; + case '>=': return actualValue >= value; + case '<': return actualValue < value; + case '<=': return actualValue <= value; + case '==': return actualValue == value; + case '!=': return actualValue != value; + default: return false; + } +}; + +/** + * Evaluate velocity conditions + */ +anomalyRuleSchema.methods.evaluateVelocity = function(data) { + // This requires historical transaction data + // Should be implemented in the service layer + return data.velocityScore !== undefined && data.velocityScore > (this.conditions.get('threshold') || 80); +}; + +/** + * Evaluate pattern conditions + */ +anomalyRuleSchema.methods.evaluatePattern = function(data) { + // Pattern detection requires sequence analysis + // Should be implemented in the service layer + return data.patternScore !== undefined && data.patternScore > (this.conditions.get('threshold') || 80); +}; + +/** + * Evaluate geo conditions + */ +anomalyRuleSchema.methods.evaluateGeo = function(data) { + const allowedCountries = this.conditions.get('allowedCountries'); + const blockedCountries = this.conditions.get('blockedCountries'); + const country = data.country || data.location?.country; + + if (!country) return false; + + if (blockedCountries && blockedCountries.includes(country)) { + return true; // Trigger on blocked country + } + + if (allowedCountries && allowedCountries.length > 0) { + return !allowedCountries.includes(country); // Trigger if not in allowed list + } + + return false; +}; + +/** + * Evaluate behavioral conditions + */ +anomalyRuleSchema.methods.evaluateBehavioral = function(data, userProfile) { + if (!userProfile) return false; + + const threshold = this.conditions.get('deviationThreshold') || 2.5; + const behavioralScore = data.behavioralScore || 0; + + return behavioralScore > threshold; +}; + +/** + * Get severity weight for scoring + */ +anomalyRuleSchema.methods.getSeverityWeight = function() { + const weights = { + low: 1, + medium: 2, + high: 3, + critical: 5 + }; + return weights[this.severity] || 1; +}; + +/** + * Clone rule with new name + */ +anomalyRuleSchema.methods.clone = async function(newName, userId) { + const Rule = this.constructor; + + const clonedRule = new Rule({ + name: newName, + description: this.description, + type: this.type, + conditions: this.conditions, + severity: this.severity, + action: this.action, + isActive: false, // Start as inactive + priority: this.priority, + cooldownPeriod: this.cooldownPeriod, + notificationChannels: this.notificationChannels, + tags: [...this.tags, 'cloned'], + createdBy: userId + }); + + return await clonedRule.save(); +}; + +// Static methods + +/** + * Get active rules for evaluation + */ +anomalyRuleSchema.statics.getActiveRules = async function(type = null) { + const query = { isActive: true }; + if (type) query.type = type; + + return await this.find(query) + .sort({ priority: -1, severity: -1 }) + .lean(); +}; + +/** + * Get rules by severity + */ +anomalyRuleSchema.statics.getRulesBySeverity = async function(severity) { + return await this.find({ severity, isActive: true }) + .sort({ priority: -1 }) + .lean(); +}; + +/** + * Get rules by creator + */ +anomalyRuleSchema.statics.getRulesByCreator = async function(userId) { + return await this.find({ createdBy: userId }) + .sort({ createdAt: -1 }) + .lean(); +}; + +/** + * Get top performing rules + */ +anomalyRuleSchema.statics.getTopPerformingRules = async function(limit = 10) { + return await this.find({ + isActive: true, + 'detections.total': { $gte: 10 } // Minimum detections for statistical significance + }) + .sort({ accuracy: -1, 'detections.truePositives': -1 }) + .limit(limit) + .lean(); +}; + +/** + * Get underperforming rules + */ +anomalyRuleSchema.statics.getUnderperformingRules = async function(accuracyThreshold = 50) { + return await this.find({ + isActive: true, + accuracy: { $lt: accuracyThreshold }, + 'detections.total': { $gte: 20 } // Minimum detections + }) + .sort({ accuracy: 1 }) + .lean(); +}; + +/** + * Get rules statistics + */ +anomalyRuleSchema.statics.getStatistics = async function() { + const stats = await this.aggregate([ + { + $group: { + _id: null, + totalRules: { $sum: 1 }, + activeRules: { + $sum: { $cond: ['$isActive', 1, 0] } + }, + totalDetections: { $sum: '$detections.total' }, + truePositives: { $sum: '$detections.truePositives' }, + falsePositives: { $sum: '$detections.falsePositives' }, + pending: { $sum: '$detections.pending' }, + avgAccuracy: { $avg: '$accuracy' } + } + } + ]); + + if (stats.length === 0) { + return { + totalRules: 0, + activeRules: 0, + totalDetections: 0, + truePositives: 0, + falsePositives: 0, + pending: 0, + avgAccuracy: 0, + overallAccuracy: 0 + }; + } + + const result = stats[0]; + const reviewed = result.truePositives + result.falsePositives; + result.overallAccuracy = reviewed > 0 ? (result.truePositives / reviewed) * 100 : 0; + + return result; +}; + +/** + * Get rules by type statistics + */ +anomalyRuleSchema.statics.getTypeStatistics = async function() { + return await this.aggregate([ + { + $group: { + _id: '$type', + count: { $sum: 1 }, + active: { + $sum: { $cond: ['$isActive', 1, 0] } + }, + totalDetections: { $sum: '$detections.total' }, + avgAccuracy: { $avg: '$accuracy' } + } + }, + { $sort: { totalDetections: -1 } } + ]); +}; + +const AnomalyRule = mongoose.model('AnomalyRule', anomalyRuleSchema); + +module.exports = AnomalyRule; diff --git a/models/BlockedEntity.js b/models/BlockedEntity.js new file mode 100644 index 00000000..c2198248 --- /dev/null +++ b/models/BlockedEntity.js @@ -0,0 +1,691 @@ +const mongoose = require('mongoose'); + +/** + * BlockedEntity Schema + * Manages blacklist of merchants, IPs, devices, and cards + */ +const blockedEntitySchema = new mongoose.Schema({ + type: { + type: String, + required: [true, 'Entity type is required'], + enum: { + values: ['merchant', 'ip', 'device', 'card', 'email', 'phone', 'country', 'user'], + message: '{VALUE} is not a valid entity type' + }, + index: true + }, + value: { + type: String, + required: [true, 'Entity value is required'], + index: true, + trim: true + }, + // Hashed version for sensitive data (card numbers, emails) + hashedValue: { + type: String, + index: true + }, + reason: { + type: String, + required: [true, 'Block reason is required'], + enum: { + values: [ + 'confirmed_fraud', + 'repeated_chargebacks', + 'suspicious_activity', + 'identity_theft', + 'account_takeover', + 'multiple_violations', + 'high_risk_region', + 'known_fraudster', + 'compliance_violation', + 'user_request', + 'manual_review', + 'automated_detection', + 'third_party_report', + 'payment_abuse', + 'terms_violation' + ], + message: '{VALUE} is not a valid block reason' + } + }, + severity: { + type: String, + enum: ['low', 'medium', 'high', 'critical'], + default: 'medium', + index: true + }, + // Block details + details: { + description: String, + associatedTransactions: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'Expense' + }], + associatedUsers: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }], + associatedEvents: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'AnomalyEvent' + }], + evidence: { + type: Map, + of: mongoose.Schema.Types.Mixed + }, + metadata: { + type: Map, + of: mongoose.Schema.Types.Mixed + } + }, + // Block scope + scope: { + type: String, + enum: ['global', 'platform', 'user_specific'], + default: 'platform', + required: true + }, + // For user-specific blocks + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + index: true + }, + // Expiration + expiresAt: { + type: Date, + index: true + }, + isPermanent: { + type: Boolean, + default: false, + index: true + }, + // Status + isActive: { + type: Boolean, + default: true, + index: true + }, + // Management + addedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'Added by is required'] + }, + addedAt: { + type: Date, + default: Date.now, + index: true + }, + updatedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + // Removal tracking + removedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + removedAt: { + type: Date + }, + removalReason: { + type: String + }, + // Block effectiveness + hits: { + total: { + type: Number, + default: 0 + }, + last30Days: { + type: Number, + default: 0 + }, + lastHitAt: Date + }, + preventedTransactions: { + type: Number, + default: 0 + }, + preventedLoss: { + type: Number, + default: 0 + }, + // Review and appeals + reviewRequired: { + type: Boolean, + default: false, + index: true + }, + lastReviewedAt: Date, + lastReviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + appealCount: { + type: Number, + default: 0 + }, + appeals: [{ + submittedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + submittedAt: { + type: Date, + default: Date.now + }, + reason: String, + status: { + type: String, + enum: ['pending', 'approved', 'rejected'], + default: 'pending' + }, + reviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + reviewedAt: Date, + reviewNotes: String + }], + // Source information + source: { + type: { + type: String, + enum: ['internal', 'external_api', 'user_report', 'automated_system', 'manual_entry'], + default: 'manual_entry' + }, + provider: String, + externalId: String, + confidence: { + type: Number, + min: 0, + max: 100 + } + }, + // Related blocks + relatedBlocks: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'BlockedEntity' + }], + // Additional attributes for specific types + attributes: { + // For merchant blocks + merchantCategory: String, + merchantCountry: String, + + // For IP blocks + ipRange: String, + asn: String, + isp: String, + + // For device blocks + deviceType: String, + deviceModel: String, + osVersion: String, + + // For card blocks + cardType: String, + cardBrand: String, + lastFourDigits: String, + + // For country blocks + countryCode: String, + region: String + }, + // Notification settings + notifications: { + alertOnHit: { + type: Boolean, + default: false + }, + notifyUsers: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }] + }, + // Tags for organization + tags: [{ + type: String, + trim: true + }], + // Notes and comments + notes: [{ + text: String, + addedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + addedAt: { + type: Date, + default: Date.now + } + }] +}, { + timestamps: true +}); + +// Indexes +blockedEntitySchema.index({ type: 1, value: 1 }); +blockedEntitySchema.index({ type: 1, isActive: 1 }); +blockedEntitySchema.index({ scope: 1, isActive: 1 }); +blockedEntitySchema.index({ expiresAt: 1, isActive: 1 }); +blockedEntitySchema.index({ hashedValue: 1 }); +blockedEntitySchema.index({ userId: 1, type: 1, isActive: 1 }); +blockedEntitySchema.index({ addedAt: -1 }); +blockedEntitySchema.index({ 'hits.lastHitAt': -1 }); + +// Compound indexes +blockedEntitySchema.index({ type: 1, value: 1, userId: 1, isActive: 1 }); +blockedEntitySchema.index({ severity: 1, isActive: 1 }); + +// Virtual for is expired +blockedEntitySchema.virtual('isExpired').get(function() { + if (this.isPermanent) return false; + if (!this.expiresAt) return false; + return new Date() > this.expiresAt; +}); + +// Virtual for days until expiration +blockedEntitySchema.virtual('daysUntilExpiration').get(function() { + if (this.isPermanent) return Infinity; + if (!this.expiresAt) return null; + + const now = new Date(); + const diff = this.expiresAt - now; + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +}); + +// Virtual for effectiveness rate +blockedEntitySchema.virtual('effectivenessRate').get(function() { + if (this.hits.total === 0) return 0; + return (this.preventedTransactions / this.hits.total) * 100; +}); + +// Methods + +/** + * Record a hit on this blocked entity + */ +blockedEntitySchema.methods.recordHit = async function(preventedAmount = 0) { + this.hits.total += 1; + this.hits.last30Days += 1; + this.hits.lastHitAt = new Date(); + + if (preventedAmount > 0) { + this.preventedTransactions += 1; + this.preventedLoss += preventedAmount; + } + + // Send notification if configured + if (this.notifications.alertOnHit && this.notifications.notifyUsers.length > 0) { + // Notification logic would be implemented in service layer + } + + return await this.save(); +}; + +/** + * Extend expiration date + */ +blockedEntitySchema.methods.extendExpiration = async function(days, userId) { + if (this.isPermanent) { + throw new Error('Cannot extend permanent blocks'); + } + + const currentExpiry = this.expiresAt || new Date(); + this.expiresAt = new Date(currentExpiry.getTime() + days * 24 * 60 * 60 * 1000); + this.updatedBy = userId; + + return await this.save(); +}; + +/** + * Make block permanent + */ +blockedEntitySchema.methods.makePermanent = async function(userId, reason) { + this.isPermanent = true; + this.expiresAt = null; + this.updatedBy = userId; + + this.notes.push({ + text: `Made permanent: ${reason}`, + addedBy: userId, + addedAt: new Date() + }); + + return await this.save(); +}; + +/** + * Deactivate block + */ +blockedEntitySchema.methods.deactivate = async function(userId, reason) { + this.isActive = false; + this.removedBy = userId; + this.removedAt = new Date(); + this.removalReason = reason; + + return await this.save(); +}; + +/** + * Reactivate block + */ +blockedEntitySchema.methods.reactivate = async function(userId) { + this.isActive = true; + this.removedBy = null; + this.removedAt = null; + this.removalReason = null; + this.updatedBy = userId; + + return await this.save(); +}; + +/** + * Submit appeal + */ +blockedEntitySchema.methods.submitAppeal = async function(userId, reason) { + this.appeals.push({ + submittedBy: userId, + reason, + status: 'pending' + }); + + this.appealCount += 1; + + return await this.save(); +}; + +/** + * Review appeal + */ +blockedEntitySchema.methods.reviewAppeal = async function(appealId, status, reviewerId, notes) { + const appeal = this.appeals.id(appealId); + + if (!appeal) { + throw new Error('Appeal not found'); + } + + appeal.status = status; + appeal.reviewedBy = reviewerId; + appeal.reviewedAt = new Date(); + appeal.reviewNotes = notes; + + // If approved, deactivate the block + if (status === 'approved') { + await this.deactivate(reviewerId, `Appeal approved: ${notes}`); + } + + return await this.save(); +}; + +/** + * Add note + */ +blockedEntitySchema.methods.addNote = async function(text, userId) { + this.notes.push({ + text, + addedBy: userId, + addedAt: new Date() + }); + + return await this.save(); +}; + +/** + * Link related block + */ +blockedEntitySchema.methods.linkRelatedBlock = async function(blockId) { + if (!this.relatedBlocks.includes(blockId)) { + this.relatedBlocks.push(blockId); + return await this.save(); + } + return this; +}; + +/** + * Mark for review + */ +blockedEntitySchema.methods.markForReview = async function() { + this.reviewRequired = true; + return await this.save(); +}; + +/** + * Complete review + */ +blockedEntitySchema.methods.completeReview = async function(reviewerId) { + this.reviewRequired = false; + this.lastReviewedAt = new Date(); + this.lastReviewedBy = reviewerId; + return await this.save(); +}; + +/** + * Check if entity matches + */ +blockedEntitySchema.methods.matches = function(value, userId = null) { + if (!this.isActive) return false; + if (this.isExpired) return false; + + // Check scope + if (this.scope === 'user_specific' && (!userId || !this.userId.equals(userId))) { + return false; + } + + // Check value match + if (this.value === value) return true; + + // Check hashed value if applicable + if (this.hashedValue && this.hashValue(value) === this.hashedValue) { + return true; + } + + // Check IP range for IP type + if (this.type === 'ip' && this.attributes?.ipRange) { + return this.isIPInRange(value, this.attributes.ipRange); + } + + return false; +}; + +/** + * Hash sensitive value + */ +blockedEntitySchema.methods.hashValue = function(value) { + const crypto = require('crypto'); + return crypto.createHash('sha256').update(value).digest('hex'); +}; + +/** + * Check if IP is in range + */ +blockedEntitySchema.methods.isIPInRange = function(ip, range) { + // Simplified IP range check - would need proper CIDR implementation + return range.split('/')[0] === ip.split('.').slice(0, 3).join('.'); +}; + +// Static methods + +/** + * Check if entity is blocked + */ +blockedEntitySchema.statics.isBlocked = async function(type, value, userId = null) { + const query = { + type, + isActive: true, + $or: [ + { value }, + { hashedValue: this.prototype.hashValue(value) } + ] + }; + + // Check expiration + query.$or.push( + { isPermanent: true }, + { expiresAt: { $gt: new Date() } } + ); + + // Check scope + if (userId) { + query.$or = [ + { scope: 'global' }, + { scope: 'platform' }, + { scope: 'user_specific', userId } + ]; + } else { + query.scope = { $in: ['global', 'platform'] }; + } + + const block = await this.findOne(query); + + if (block) { + await block.recordHit(); + return { blocked: true, block }; + } + + return { blocked: false, block: null }; +}; + +/** + * Get active blocks + */ +blockedEntitySchema.statics.getActiveBlocks = async function(type = null, scope = null) { + const query = { + isActive: true, + $or: [ + { isPermanent: true }, + { expiresAt: { $gt: new Date() } } + ] + }; + + if (type) query.type = type; + if (scope) query.scope = scope; + + return await this.find(query) + .sort({ addedAt: -1 }) + .populate('addedBy', 'name email') + .lean(); +}; + +/** + * Get blocks by user + */ +blockedEntitySchema.statics.getBlocksByUser = async function(userId) { + return await this.find({ + $or: [ + { userId }, + { 'details.associatedUsers': userId } + ] + }) + .sort({ addedAt: -1 }) + .lean(); +}; + +/** + * Get expiring blocks + */ +blockedEntitySchema.statics.getExpiringBlocks = async function(daysAhead = 7) { + const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000); + + return await this.find({ + isActive: true, + isPermanent: false, + expiresAt: { + $gt: new Date(), + $lte: futureDate + } + }) + .sort({ expiresAt: 1 }) + .populate('addedBy', 'name email') + .lean(); +}; + +/** + * Clean up expired blocks + */ +blockedEntitySchema.statics.cleanupExpired = async function() { + const result = await this.updateMany( + { + isActive: true, + isPermanent: false, + expiresAt: { $lte: new Date() } + }, + { + $set: { + isActive: false, + removedAt: new Date(), + removalReason: 'Automatically expired' + } + } + ); + + return result.modifiedCount; +}; + +/** + * Get blocks requiring review + */ +blockedEntitySchema.statics.getBlocksRequiringReview = async function() { + return await this.find({ reviewRequired: true }) + .sort({ addedAt: 1 }) + .populate('addedBy', 'name email') + .lean(); +}; + +/** + * Get statistics + */ +blockedEntitySchema.statics.getStatistics = async function() { + const stats = await this.aggregate([ + { + $group: { + _id: '$type', + total: { $sum: 1 }, + active: { + $sum: { $cond: ['$isActive', 1, 0] } + }, + permanent: { + $sum: { $cond: ['$isPermanent', 1, 0] } + }, + totalHits: { $sum: '$hits.total' }, + preventedLoss: { $sum: '$preventedLoss' } + } + }, + { $sort: { total: -1 } } + ]); + + return stats; +}; + +/** + * Get most effective blocks + */ +blockedEntitySchema.statics.getMostEffectiveBlocks = async function(limit = 10) { + return await this.find({ + isActive: true, + 'hits.total': { $gte: 1 } + }) + .sort({ preventedLoss: -1, 'hits.total': -1 }) + .limit(limit) + .populate('addedBy', 'name email') + .lean(); +}; + +/** + * Reset 30-day hit counters + */ +blockedEntitySchema.statics.reset30DayCounters = async function() { + return await this.updateMany( + {}, + { $set: { 'hits.last30Days': 0 } } + ); +}; + +const BlockedEntity = mongoose.model('BlockedEntity', blockedEntitySchema); + +module.exports = BlockedEntity; diff --git a/models/RiskScore.js b/models/RiskScore.js new file mode 100644 index 00000000..993ec956 --- /dev/null +++ b/models/RiskScore.js @@ -0,0 +1,677 @@ +const mongoose = require('mongoose'); + +/** + * RiskScore Schema + * Calculates and tracks overall user risk levels + */ +const riskScoreSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'User ID is required'], + index: true + }, + overallScore: { + type: Number, + required: [true, 'Overall score is required'], + min: [0, 'Score must be between 0 and 100'], + max: [100, 'Score must be between 0 and 100'], + index: true + }, + riskLevel: { + type: String, + enum: ['minimal', 'low', 'medium', 'high', 'critical'], + required: true, + index: true + }, + // Individual risk factors + factors: [{ + name: { + type: String, + required: true, + enum: [ + 'transaction_velocity', + 'high_value_transactions', + 'unusual_patterns', + 'geographic_risk', + 'behavioral_deviation', + 'device_anomalies', + 'merchant_risk', + 'account_age', + 'verification_status', + 'historical_fraud', + 'failed_transactions', + 'suspicious_activities', + 'chargebacks', + 'multiple_devices', + 'vpn_proxy_usage', + 'blacklist_hits' + ] + }, + score: { + type: Number, + min: 0, + max: 100, + required: true + }, + weight: { + type: Number, + min: 0, + max: 1, + required: true + }, + description: String, + severity: { + type: String, + enum: ['low', 'medium', 'high', 'critical'] + }, + evidence: { + type: Map, + of: mongoose.Schema.Types.Mixed + }, + lastUpdated: { + type: Date, + default: Date.now + } + }], + // Historical tracking + scoreHistory: [{ + score: Number, + timestamp: { + type: Date, + default: Date.now + }, + triggerEvent: String, + changedFactors: [String] + }], + trend: { + type: String, + enum: ['increasing', 'stable', 'decreasing'], + default: 'stable', + index: true + }, + trendPercentage: { + // Percentage change over last 7 days + type: Number, + default: 0 + }, + // Prediction + predictedScore: { + next7Days: Number, + next30Days: Number, + confidence: { + type: Number, + min: 0, + max: 100 + } + }, + // Thresholds and alerts + thresholds: { + warning: { + type: Number, + default: 60 + }, + critical: { + type: Number, + default: 80 + } + }, + alerts: [{ + level: { + type: String, + enum: ['warning', 'critical'] + }, + triggeredAt: { + type: Date, + default: Date.now + }, + acknowledged: { + type: Boolean, + default: false + }, + acknowledgedAt: Date, + acknowledgedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } + }], + // Mitigation actions + mitigationActions: [{ + action: { + type: String, + enum: [ + 'increase_monitoring', + 'require_verification', + 'limit_transactions', + 'manual_review', + 'account_restriction', + 'enhanced_authentication', + 'contact_user' + ] + }, + status: { + type: String, + enum: ['pending', 'in_progress', 'completed', 'failed'], + default: 'pending' + }, + assignedTo: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + createdAt: { + type: Date, + default: Date.now + }, + completedAt: Date, + notes: String + }], + // Compliance and regulations + complianceFlags: [{ + regulation: { + type: String, + enum: ['AML', 'KYC', 'PSD2', 'GDPR', 'PCI_DSS'] + }, + status: { + type: String, + enum: ['compliant', 'warning', 'violation'] + }, + details: String, + flaggedAt: { + type: Date, + default: Date.now + } + }], + // Metadata + calculatedAt: { + type: Date, + default: Date.now, + index: true + }, + lastRecalculated: { + type: Date, + default: Date.now + }, + calculationMethod: { + type: String, + enum: ['weighted_average', 'ml_model', 'rule_based', 'hybrid'], + default: 'weighted_average' + }, + modelVersion: { + type: String, + default: '1.0' + }, + confidenceScore: { + // Confidence in the risk score accuracy + type: Number, + min: 0, + max: 100, + default: 50 + }, + // Review status + reviewRequired: { + type: Boolean, + default: false, + index: true + }, + reviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + reviewedAt: Date, + reviewNotes: String, + // External data integration + externalRiskData: { + creditScore: Number, + fraudDatabases: [{ + source: String, + score: Number, + lastChecked: Date + }], + identityVerification: { + verified: Boolean, + verifiedAt: Date, + provider: String + } + } +}, { + timestamps: true +}); + +// Indexes +riskScoreSchema.index({ userId: 1, calculatedAt: -1 }); +riskScoreSchema.index({ overallScore: -1, calculatedAt: -1 }); +riskScoreSchema.index({ riskLevel: 1, reviewRequired: 1 }); +riskScoreSchema.index({ trend: 1, overallScore: -1 }); + +// Compound indexes for complex queries +riskScoreSchema.index({ userId: 1, riskLevel: 1 }); +riskScoreSchema.index({ 'alerts.acknowledged': 1, 'alerts.level': 1 }); + +// Virtual for is high risk +riskScoreSchema.virtual('isHighRisk').get(function() { + return this.overallScore >= this.thresholds.critical; +}); + +// Virtual for requires action +riskScoreSchema.virtual('requiresAction').get(function() { + return this.overallScore >= this.thresholds.warning || this.reviewRequired; +}); + +// Virtual for unacknowledged alerts +riskScoreSchema.virtual('unacknowledgedAlerts').get(function() { + return this.alerts.filter(a => !a.acknowledged); +}); + +// Methods + +/** + * Calculate overall score from factors + */ +riskScoreSchema.methods.calculateOverallScore = function() { + if (this.factors.length === 0) { + this.overallScore = 0; + this.riskLevel = 'minimal'; + return; + } + + let weightedSum = 0; + let totalWeight = 0; + + for (const factor of this.factors) { + weightedSum += factor.score * factor.weight; + totalWeight += factor.weight; + } + + this.overallScore = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0; + + // Update risk level + if (this.overallScore >= 80) { + this.riskLevel = 'critical'; + } else if (this.overallScore >= 65) { + this.riskLevel = 'high'; + } else if (this.overallScore >= 40) { + this.riskLevel = 'medium'; + } else if (this.overallScore >= 20) { + this.riskLevel = 'low'; + } else { + this.riskLevel = 'minimal'; + } +}; + +/** + * Add or update a risk factor + */ +riskScoreSchema.methods.updateFactor = function(name, score, weight, description, evidence = {}) { + let factor = this.factors.find(f => f.name === name); + + if (!factor) { + factor = { + name, + score, + weight, + description, + evidence: new Map(Object.entries(evidence)), + lastUpdated: new Date() + }; + this.factors.push(factor); + } else { + const oldScore = factor.score; + factor.score = score; + factor.weight = weight; + factor.description = description; + factor.evidence = new Map(Object.entries(evidence)); + factor.lastUpdated = new Date(); + + // Record in history if significant change + if (Math.abs(oldScore - score) >= 10) { + this.recordScoreChange(name); + } + } + + // Determine severity + if (score >= 80) { + factor.severity = 'critical'; + } else if (score >= 60) { + factor.severity = 'high'; + } else if (score >= 40) { + factor.severity = 'medium'; + } else { + factor.severity = 'low'; + } + + this.calculateOverallScore(); +}; + +/** + * Remove a risk factor + */ +riskScoreSchema.methods.removeFactor = function(name) { + this.factors = this.factors.filter(f => f.name !== name); + this.calculateOverallScore(); +}; + +/** + * Record score change in history + */ +riskScoreSchema.methods.recordScoreChange = function(triggerEvent = null, changedFactors = []) { + this.scoreHistory.push({ + score: this.overallScore, + timestamp: new Date(), + triggerEvent, + changedFactors + }); + + // Keep only last 100 entries + if (this.scoreHistory.length > 100) { + this.scoreHistory = this.scoreHistory.slice(-100); + } + + // Update trend + this.updateTrend(); +}; + +/** + * Update trend based on recent history + */ +riskScoreSchema.methods.updateTrend = function() { + if (this.scoreHistory.length < 2) { + this.trend = 'stable'; + this.trendPercentage = 0; + return; + } + + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const recentScores = this.scoreHistory + .filter(h => h.timestamp >= sevenDaysAgo) + .map(h => h.score); + + if (recentScores.length < 2) { + this.trend = 'stable'; + this.trendPercentage = 0; + return; + } + + const oldScore = recentScores[0]; + const newScore = recentScores[recentScores.length - 1]; + const change = newScore - oldScore; + const changePercent = oldScore > 0 ? (change / oldScore) * 100 : 0; + + this.trendPercentage = Math.round(changePercent); + + if (changePercent > 10) { + this.trend = 'increasing'; + } else if (changePercent < -10) { + this.trend = 'decreasing'; + } else { + this.trend = 'stable'; + } +}; + +/** + * Check and trigger alerts + */ +riskScoreSchema.methods.checkAlerts = function() { + const currentScore = this.overallScore; + + // Check for critical alert + if (currentScore >= this.thresholds.critical) { + const existingCritical = this.alerts.find( + a => a.level === 'critical' && !a.acknowledged + ); + + if (!existingCritical) { + this.alerts.push({ + level: 'critical', + triggeredAt: new Date(), + acknowledged: false + }); + } + } + + // Check for warning alert + else if (currentScore >= this.thresholds.warning) { + const existingWarning = this.alerts.find( + a => a.level === 'warning' && !a.acknowledged + ); + + if (!existingWarning) { + this.alerts.push({ + level: 'warning', + triggeredAt: new Date(), + acknowledged: false + }); + } + } +}; + +/** + * Acknowledge alert + */ +riskScoreSchema.methods.acknowledgeAlert = function(alertId, userId) { + const alert = this.alerts.id(alertId); + + if (alert && !alert.acknowledged) { + alert.acknowledged = true; + alert.acknowledgedAt = new Date(); + alert.acknowledgedBy = userId; + } +}; + +/** + * Add mitigation action + */ +riskScoreSchema.methods.addMitigationAction = function(action, assignedTo = null, notes = null) { + this.mitigationActions.push({ + action, + status: 'pending', + assignedTo, + notes, + createdAt: new Date() + }); +}; + +/** + * Update mitigation action status + */ +riskScoreSchema.methods.updateMitigationAction = function(actionId, status, notes = null) { + const action = this.mitigationActions.id(actionId); + + if (action) { + action.status = status; + if (notes) action.notes = notes; + if (status === 'completed') { + action.completedAt = new Date(); + } + } +}; + +/** + * Get top risk factors + */ +riskScoreSchema.methods.getTopRiskFactors = function(limit = 5) { + return this.factors + .sort((a, b) => (b.score * b.weight) - (a.score * a.weight)) + .slice(0, limit); +}; + +/** + * Get recommended actions + */ +riskScoreSchema.methods.getRecommendedActions = function() { + const recommendations = []; + + if (this.overallScore >= 80) { + recommendations.push('account_restriction', 'manual_review', 'contact_user'); + } else if (this.overallScore >= 65) { + recommendations.push('require_verification', 'increase_monitoring', 'enhanced_authentication'); + } else if (this.overallScore >= 40) { + recommendations.push('increase_monitoring', 'require_verification'); + } + + // Check specific factors + const highRiskFactors = this.factors.filter(f => f.score >= 70); + for (const factor of highRiskFactors) { + if (factor.name === 'transaction_velocity') { + recommendations.push('limit_transactions'); + } + if (factor.name === 'device_anomalies') { + recommendations.push('enhanced_authentication'); + } + } + + return [...new Set(recommendations)]; // Remove duplicates +}; + +/** + * Mark for review + */ +riskScoreSchema.methods.markForReview = function(reason = null) { + this.reviewRequired = true; + if (reason) { + this.reviewNotes = reason; + } +}; + +/** + * Complete review + */ +riskScoreSchema.methods.completeReview = function(reviewerId, notes) { + this.reviewRequired = false; + this.reviewedBy = reviewerId; + this.reviewedAt = new Date(); + this.reviewNotes = notes; +}; + +// Static methods + +/** + * Get latest risk score for user + */ +riskScoreSchema.statics.getLatestForUser = async function(userId) { + return await this.findOne({ userId }) + .sort({ calculatedAt: -1 }) + .populate('userId', 'name email'); +}; + +/** + * Get high risk users + */ +riskScoreSchema.statics.getHighRiskUsers = async function(threshold = 65) { + return await this.aggregate([ + { + $sort: { userId: 1, calculatedAt: -1 } + }, + { + $group: { + _id: '$userId', + latestScore: { $first: '$$ROOT' } + } + }, + { + $replaceRoot: { newRoot: '$latestScore' } + }, + { + $match: { overallScore: { $gte: threshold } } + }, + { + $sort: { overallScore: -1 } + } + ]); +}; + +/** + * Get users requiring review + */ +riskScoreSchema.statics.getUsersRequiringReview = async function() { + return await this.aggregate([ + { + $sort: { userId: 1, calculatedAt: -1 } + }, + { + $group: { + _id: '$userId', + latestScore: { $first: '$$ROOT' } + } + }, + { + $replaceRoot: { newRoot: '$latestScore' } + }, + { + $match: { reviewRequired: true } + }, + { + $sort: { overallScore: -1 } + } + ]); +}; + +/** + * Get risk distribution statistics + */ +riskScoreSchema.statics.getRiskDistribution = async function() { + const latestScores = await this.aggregate([ + { + $sort: { userId: 1, calculatedAt: -1 } + }, + { + $group: { + _id: '$userId', + latestScore: { $first: '$$ROOT' } + } + }, + { + $replaceRoot: { newRoot: '$latestScore' } + }, + { + $group: { + _id: '$riskLevel', + count: { $sum: 1 }, + avgScore: { $avg: '$overallScore' } + } + } + ]); + + return latestScores; +}; + +/** + * Get trending risk users + */ +riskScoreSchema.statics.getTrendingRiskUsers = async function(trendType = 'increasing') { + return await this.aggregate([ + { + $sort: { userId: 1, calculatedAt: -1 } + }, + { + $group: { + _id: '$userId', + latestScore: { $first: '$$ROOT' } + } + }, + { + $replaceRoot: { newRoot: '$latestScore' } + }, + { + $match: { trend: trendType } + }, + { + $sort: { trendPercentage: -1 } + } + ]); +}; + +/** + * Get users with unacknowledged alerts + */ +riskScoreSchema.statics.getUsersWithUnacknowledgedAlerts = async function() { + return await this.find({ + 'alerts.acknowledged': false + }) + .sort({ 'alerts.triggeredAt': 1 }) + .populate('userId', 'name email') + .lean(); +}; + +const RiskScore = mongoose.model('RiskScore', riskScoreSchema); + +module.exports = RiskScore; diff --git a/models/UserBehaviorProfile.js b/models/UserBehaviorProfile.js new file mode 100644 index 00000000..f0395d6c --- /dev/null +++ b/models/UserBehaviorProfile.js @@ -0,0 +1,692 @@ +const mongoose = require('mongoose'); + +/** + * UserBehaviorProfile Schema + * Tracks and analyzes user spending behavior for anomaly detection + */ +const userBehaviorProfileSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'User ID is required'], + unique: true, + index: true + }, + // Spending patterns + avgDailySpend: { + type: Number, + default: 0, + min: [0, 'Average daily spend cannot be negative'] + }, + avgTransactionSize: { + type: Number, + default: 0, + min: [0, 'Average transaction size cannot be negative'] + }, + medianTransactionSize: { + type: Number, + default: 0 + }, + maxTransactionSize: { + type: Number, + default: 0 + }, + minTransactionSize: { + type: Number, + default: Number.MAX_VALUE + }, + transactionSizeStdDev: { + type: Number, + default: 0 + }, + // Spending variability + spendingVariability: { + coefficient: { + type: Number, + default: 0 + }, + trend: { + type: String, + enum: ['increasing', 'stable', 'decreasing', 'volatile'], + default: 'stable' + } + }, + // Category preferences + typicalCategories: [{ + category: { + type: String, + required: true + }, + frequency: { + type: Number, + default: 0 + }, + avgAmount: { + type: Number, + default: 0 + }, + percentage: { + type: Number, + default: 0 + }, + lastTransaction: Date + }], + // Merchant preferences + typicalMerchants: [{ + merchant: { + type: String, + required: true + }, + frequency: { + type: Number, + default: 0 + }, + avgAmount: { + type: Number, + default: 0 + }, + lastTransaction: Date, + isTrusted: { + type: Boolean, + default: false + } + }], + // Temporal patterns + activeHours: [{ + hour: { + type: Number, + min: 0, + max: 23, + required: true + }, + transactionCount: { + type: Number, + default: 0 + }, + avgAmount: { + type: Number, + default: 0 + } + }], + activeDaysOfWeek: [{ + day: { + type: Number, + min: 0, + max: 6, + required: true + }, + transactionCount: { + type: Number, + default: 0 + }, + avgAmount: { + type: Number, + default: 0 + } + }], + // Geographic patterns + typicalLocations: [{ + country: { + type: String, + required: true + }, + city: String, + coordinates: { + lat: Number, + lng: Number + }, + frequency: { + type: Number, + default: 0 + }, + lastSeen: Date, + radius: { + // Typical distance from this location (in km) + type: Number, + default: 50 + } + }], + homeLocation: { + country: String, + city: String, + coordinates: { + lat: Number, + lng: Number + } + }, + // Device patterns + deviceFingerprints: [{ + fingerprint: { + type: String, + required: true + }, + deviceType: { + type: String, + enum: ['mobile', 'tablet', 'desktop', 'other'] + }, + firstSeen: Date, + lastSeen: Date, + transactionCount: { + type: Number, + default: 0 + }, + isTrusted: { + type: Boolean, + default: false + }, + ipAddresses: [{ + ip: String, + lastSeen: Date + }] + }], + // Transaction velocity patterns + velocityProfile: { + avgTransactionsPerDay: { + type: Number, + default: 0 + }, + maxTransactionsPerDay: { + type: Number, + default: 0 + }, + avgTransactionsPerHour: { + type: Number, + default: 0 + }, + maxTransactionsPerHour: { + type: Number, + default: 0 + } + }, + // Risk indicators + riskIndicators: { + highValueTransactions: { + type: Number, + default: 0 + }, + internationalTransactions: { + type: Number, + default: 0 + }, + declinedTransactions: { + type: Number, + default: 0 + }, + chargebacks: { + type: Number, + default: 0 + }, + suspiciousActivities: { + type: Number, + default: 0 + } + }, + // Statistical metadata + statistics: { + totalTransactions: { + type: Number, + default: 0 + }, + totalSpend: { + type: Number, + default: 0 + }, + firstTransaction: Date, + lastTransaction: Date, + accountAgeInDays: { + type: Number, + default: 0 + }, + dataQuality: { + type: String, + enum: ['low', 'medium', 'high'], + default: 'low' + } + }, + // Learning metadata + modelVersion: { + type: String, + default: '1.0' + }, + lastUpdated: { + type: Date, + default: Date.now, + index: true + }, + lastFullRecalculation: { + type: Date, + default: Date.now + }, + updateFrequency: { + type: String, + enum: ['realtime', 'hourly', 'daily', 'weekly'], + default: 'daily' + }, + // Anomaly thresholds (customizable per user) + customThresholds: { + transactionAmount: Number, + transactionVelocity: Number, + geographicDistance: Number, + categoryDeviation: Number + } +}, { + timestamps: true +}); + +// Indexes +userBehaviorProfileSchema.index({ lastUpdated: 1 }); +userBehaviorProfileSchema.index({ 'statistics.dataQuality': 1 }); +userBehaviorProfileSchema.index({ 'statistics.accountAgeInDays': 1 }); + +// Virtual for profile completeness +userBehaviorProfileSchema.virtual('completeness').get(function() { + let score = 0; + const maxScore = 100; + + if (this.typicalCategories.length > 0) score += 15; + if (this.typicalMerchants.length > 0) score += 15; + if (this.activeHours.length > 0) score += 10; + if (this.typicalLocations.length > 0) score += 15; + if (this.deviceFingerprints.length > 0) score += 10; + if (this.statistics.totalTransactions >= 10) score += 10; + if (this.statistics.totalTransactions >= 50) score += 10; + if (this.statistics.accountAgeInDays >= 30) score += 15; + + return Math.min(score, maxScore); +}); + +// Virtual for is mature profile +userBehaviorProfileSchema.virtual('isMature').get(function() { + return this.statistics.totalTransactions >= 20 && + this.statistics.accountAgeInDays >= 14; +}); + +// Methods + +/** + * Update profile with new transaction + */ +userBehaviorProfileSchema.methods.updateWithTransaction = async function(transaction) { + // Update statistics + this.statistics.totalTransactions += 1; + this.statistics.totalSpend += transaction.amount || 0; + this.statistics.lastTransaction = transaction.date || new Date(); + + if (!this.statistics.firstTransaction) { + this.statistics.firstTransaction = transaction.date || new Date(); + } + + // Update account age + const accountAge = Date.now() - new Date(this.statistics.firstTransaction).getTime(); + this.statistics.accountAgeInDays = Math.floor(accountAge / (1000 * 60 * 60 * 24)); + + // Update averages + this.avgDailySpend = this.statistics.totalSpend / Math.max(this.statistics.accountAgeInDays, 1); + this.avgTransactionSize = this.statistics.totalSpend / this.statistics.totalTransactions; + + // Update category + if (transaction.category) { + this.updateCategory(transaction.category, transaction.amount); + } + + // Update merchant + if (transaction.merchant) { + this.updateMerchant(transaction.merchant, transaction.amount, transaction.date); + } + + // Update temporal patterns + if (transaction.date) { + this.updateTemporalPatterns(transaction.date, transaction.amount); + } + + // Update location + if (transaction.location) { + this.updateLocation(transaction.location, transaction.date); + } + + // Update device + if (transaction.device) { + this.updateDevice(transaction.device, transaction.date); + } + + // Update data quality + this.updateDataQuality(); + + this.lastUpdated = new Date(); + + return await this.save(); +}; + +/** + * Update category statistics + */ +userBehaviorProfileSchema.methods.updateCategory = function(category, amount) { + let cat = this.typicalCategories.find(c => c.category === category); + + if (!cat) { + cat = { + category, + frequency: 0, + avgAmount: 0, + percentage: 0, + lastTransaction: new Date() + }; + this.typicalCategories.push(cat); + } + + cat.frequency += 1; + cat.avgAmount = ((cat.avgAmount * (cat.frequency - 1)) + amount) / cat.frequency; + cat.lastTransaction = new Date(); + + // Recalculate percentages + const totalFreq = this.typicalCategories.reduce((sum, c) => sum + c.frequency, 0); + this.typicalCategories.forEach(c => { + c.percentage = (c.frequency / totalFreq) * 100; + }); + + // Keep only top 20 categories + this.typicalCategories.sort((a, b) => b.frequency - a.frequency); + if (this.typicalCategories.length > 20) { + this.typicalCategories = this.typicalCategories.slice(0, 20); + } +}; + +/** + * Update merchant statistics + */ +userBehaviorProfileSchema.methods.updateMerchant = function(merchant, amount, date) { + let merch = this.typicalMerchants.find(m => m.merchant === merchant); + + if (!merch) { + merch = { + merchant, + frequency: 0, + avgAmount: 0, + lastTransaction: date || new Date(), + isTrusted: false + }; + this.typicalMerchants.push(merch); + } + + merch.frequency += 1; + merch.avgAmount = ((merch.avgAmount * (merch.frequency - 1)) + amount) / merch.frequency; + merch.lastTransaction = date || new Date(); + + // Mark as trusted if used frequently + if (merch.frequency >= 5) { + merch.isTrusted = true; + } + + // Keep only top 50 merchants + this.typicalMerchants.sort((a, b) => b.frequency - a.frequency); + if (this.typicalMerchants.length > 50) { + this.typicalMerchants = this.typicalMerchants.slice(0, 50); + } +}; + +/** + * Update temporal patterns + */ +userBehaviorProfileSchema.methods.updateTemporalPatterns = function(date, amount) { + const hour = new Date(date).getHours(); + const day = new Date(date).getDay(); + + // Update hour + let hourData = this.activeHours.find(h => h.hour === hour); + if (!hourData) { + hourData = { hour, transactionCount: 0, avgAmount: 0 }; + this.activeHours.push(hourData); + } + hourData.transactionCount += 1; + hourData.avgAmount = ((hourData.avgAmount * (hourData.transactionCount - 1)) + amount) / hourData.transactionCount; + + // Update day + let dayData = this.activeDaysOfWeek.find(d => d.day === day); + if (!dayData) { + dayData = { day, transactionCount: 0, avgAmount: 0 }; + this.activeDaysOfWeek.push(dayData); + } + dayData.transactionCount += 1; + dayData.avgAmount = ((dayData.avgAmount * (dayData.transactionCount - 1)) + amount) / dayData.transactionCount; +}; + +/** + * Update location patterns + */ +userBehaviorProfileSchema.methods.updateLocation = function(location, date) { + let loc = this.typicalLocations.find(l => + l.country === location.country && l.city === location.city + ); + + if (!loc) { + loc = { + country: location.country, + city: location.city, + coordinates: location.coordinates, + frequency: 0, + lastSeen: date || new Date(), + radius: 50 + }; + this.typicalLocations.push(loc); + } + + loc.frequency += 1; + loc.lastSeen = date || new Date(); + + // Set home location as most frequent + this.typicalLocations.sort((a, b) => b.frequency - a.frequency); + if (this.typicalLocations.length > 0) { + const mostFrequent = this.typicalLocations[0]; + this.homeLocation = { + country: mostFrequent.country, + city: mostFrequent.city, + coordinates: mostFrequent.coordinates + }; + } + + // Keep only top 10 locations + if (this.typicalLocations.length > 10) { + this.typicalLocations = this.typicalLocations.slice(0, 10); + } +}; + +/** + * Update device fingerprint + */ +userBehaviorProfileSchema.methods.updateDevice = function(device, date) { + let dev = this.deviceFingerprints.find(d => d.fingerprint === device.fingerprint); + + if (!dev) { + dev = { + fingerprint: device.fingerprint, + deviceType: device.type, + firstSeen: date || new Date(), + lastSeen: date || new Date(), + transactionCount: 0, + isTrusted: false, + ipAddresses: [] + }; + this.deviceFingerprints.push(dev); + } + + dev.lastSeen = date || new Date(); + dev.transactionCount += 1; + + // Mark as trusted after 3 transactions + if (dev.transactionCount >= 3) { + dev.isTrusted = true; + } + + // Update IP addresses + if (device.ipAddress) { + const existingIP = dev.ipAddresses.find(ip => ip.ip === device.ipAddress); + if (existingIP) { + existingIP.lastSeen = date || new Date(); + } else { + dev.ipAddresses.push({ + ip: device.ipAddress, + lastSeen: date || new Date() + }); + } + + // Keep only recent 10 IPs + if (dev.ipAddresses.length > 10) { + dev.ipAddresses.sort((a, b) => b.lastSeen - a.lastSeen); + dev.ipAddresses = dev.ipAddresses.slice(0, 10); + } + } +}; + +/** + * Update data quality score + */ +userBehaviorProfileSchema.methods.updateDataQuality = function() { + const txCount = this.statistics.totalTransactions; + const accountAge = this.statistics.accountAgeInDays; + + if (txCount >= 50 && accountAge >= 30) { + this.statistics.dataQuality = 'high'; + } else if (txCount >= 20 && accountAge >= 14) { + this.statistics.dataQuality = 'medium'; + } else { + this.statistics.dataQuality = 'low'; + } +}; + +/** + * Calculate anomaly score for transaction + */ +userBehaviorProfileSchema.methods.calculateAnomalyScore = function(transaction) { + if (!this.isMature) { + return 0; // Don't score until profile is mature + } + + let score = 0; + const weights = { + amount: 0.3, + category: 0.15, + merchant: 0.15, + time: 0.1, + location: 0.2, + device: 0.1 + }; + + // Amount anomaly + if (transaction.amount > this.avgTransactionSize * 3) { + score += weights.amount * 100; + } else if (transaction.amount > this.avgTransactionSize * 2) { + score += weights.amount * 60; + } + + // Category anomaly + const categoryMatch = this.typicalCategories.find(c => c.category === transaction.category); + if (!categoryMatch) { + score += weights.category * 80; + } else if (categoryMatch.percentage < 5) { + score += weights.category * 40; + } + + // Merchant anomaly + const merchantMatch = this.typicalMerchants.find(m => m.merchant === transaction.merchant); + if (!merchantMatch) { + score += weights.merchant * 70; + } else if (!merchantMatch.isTrusted) { + score += weights.merchant * 30; + } + + // Time anomaly + const hour = new Date(transaction.date).getHours(); + const hourMatch = this.activeHours.find(h => h.hour === hour); + if (!hourMatch || hourMatch.transactionCount < 3) { + score += weights.time * 50; + } + + // Location anomaly + if (transaction.location) { + const locationMatch = this.typicalLocations.find(l => + l.country === transaction.location.country + ); + if (!locationMatch) { + score += weights.location * 90; + } + } + + // Device anomaly + if (transaction.device) { + const deviceMatch = this.deviceFingerprints.find(d => + d.fingerprint === transaction.device.fingerprint + ); + if (!deviceMatch) { + score += weights.device * 80; + } else if (!deviceMatch.isTrusted) { + score += weights.device * 40; + } + } + + return Math.min(Math.round(score), 100); +}; + +/** + * Full recalculation from transaction history + */ +userBehaviorProfileSchema.methods.recalculateFromHistory = async function(transactions) { + // Reset all statistics + this.typicalCategories = []; + this.typicalMerchants = []; + this.activeHours = []; + this.activeDaysOfWeek = []; + this.typicalLocations = []; + this.deviceFingerprints = []; + this.statistics.totalTransactions = 0; + this.statistics.totalSpend = 0; + + // Process all transactions + for (const tx of transactions) { + await this.updateWithTransaction(tx); + } + + this.lastFullRecalculation = new Date(); + return await this.save(); +}; + +// Static methods + +/** + * Get or create profile + */ +userBehaviorProfileSchema.statics.getOrCreateProfile = async function(userId) { + let profile = await this.findOne({ userId }); + + if (!profile) { + profile = new this({ userId }); + await profile.save(); + } + + return profile; +}; + +/** + * Get profiles needing update + */ +userBehaviorProfileSchema.statics.getProfilesNeedingUpdate = async function() { + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + return await this.find({ + updateFrequency: 'daily', + lastUpdated: { $lte: oneDayAgo } + }).lean(); +}; + +/** + * Get mature profiles + */ +userBehaviorProfileSchema.statics.getMatureProfiles = async function() { + return await this.find({ + 'statistics.totalTransactions': { $gte: 20 }, + 'statistics.accountAgeInDays': { $gte: 14 } + }).lean(); +}; + +const UserBehaviorProfile = mongoose.model('UserBehaviorProfile', userBehaviorProfileSchema); + +module.exports = UserBehaviorProfile;