rust_transaction_validator/
fraud_patterns.rs

1//! Advanced fraud detection patterns
2
3use crate::Transaction;
4use std::collections::HashMap;
5
6/// Fraud pattern detector
7pub struct FraudDetector {
8    /// Transaction history for velocity checks
9    history: HashMap<String, Vec<Transaction>>,
10    /// High-risk countries
11    high_risk_countries: Vec<String>,
12    /// Suspicious amount thresholds
13    thresholds: FraudThresholds,
14}
15
16/// Fraud detection thresholds
17#[derive(Debug, Clone)]
18pub struct FraudThresholds {
19    /// Maximum transaction amount (USD)
20    pub max_amount: f64,
21    /// Maximum transactions per hour
22    pub max_transactions_per_hour: usize,
23    /// Maximum total amount per day
24    pub max_daily_total: f64,
25    /// Suspicious round amount threshold
26    pub round_amount_threshold: f64,
27}
28
29impl Default for FraudThresholds {
30    fn default() -> Self {
31        Self {
32            max_amount: 50000.0,
33            max_transactions_per_hour: 10,
34            max_daily_total: 100000.0,
35            round_amount_threshold: 10000.0,
36        }
37    }
38}
39
40/// Fraud risk score (0-100)
41#[derive(Debug, Clone)]
42pub struct FraudScore {
43    pub score: u8,
44    pub risk_level: RiskLevel,
45    pub flags: Vec<FraudFlag>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum RiskLevel {
50    Low,      // 0-25
51    Medium,   // 26-50
52    High,     // 51-75
53    Critical, // 76-100
54}
55
56#[derive(Debug, Clone)]
57pub struct FraudFlag {
58    pub flag_type: FraudFlagType,
59    pub description: String,
60    pub severity: u8,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum FraudFlagType {
65    VelocityExceeded,
66    UnusualAmount,
67    RoundAmount,
68    HighRiskCountry,
69    DuplicateTransaction,
70    RapidSuccession,
71    AmountProgression,
72    TimeAnomaly,
73    GeographicAnomaly,
74}
75
76impl FraudDetector {
77    /// Create new fraud detector
78    pub fn new() -> Self {
79        Self {
80            history: HashMap::new(),
81            high_risk_countries: vec![
82                "KP".to_string(), // North Korea
83                "IR".to_string(), // Iran
84                "SY".to_string(), // Syria
85            ],
86            thresholds: FraudThresholds::default(),
87        }
88    }
89
90    /// Create with custom thresholds
91    pub fn with_thresholds(thresholds: FraudThresholds) -> Self {
92        let mut detector = Self::new();
93        detector.thresholds = thresholds;
94        detector
95    }
96
97    /// Calculate fraud score for transaction
98    pub fn calculate_fraud_score(&mut self, transaction: &Transaction) -> FraudScore {
99        let mut score = 0u8;
100        let mut flags = Vec::new();
101
102        // Check velocity (transactions per hour)
103        if let Some(velocity_flag) = self.check_velocity(transaction) {
104            score += velocity_flag.severity;
105            flags.push(velocity_flag);
106        }
107
108        // Check unusual amounts
109        if let Some(amount_flag) = self.check_unusual_amount(transaction) {
110            score += amount_flag.severity;
111            flags.push(amount_flag);
112        }
113
114        // Check round amounts (potential structuring)
115        if let Some(round_flag) = self.check_round_amount(transaction) {
116            score += round_flag.severity;
117            flags.push(round_flag);
118        }
119
120        // Check high-risk countries
121        if let Some(country_flag) = self.check_high_risk_country(transaction) {
122            score += country_flag.severity;
123            flags.push(country_flag);
124        }
125
126        // Check rapid succession
127        if let Some(rapid_flag) = self.check_rapid_succession(transaction) {
128            score += rapid_flag.severity;
129            flags.push(rapid_flag);
130        }
131
132        // Check amount progression (potential testing)
133        if let Some(progression_flag) = self.check_amount_progression(transaction) {
134            score += progression_flag.severity;
135            flags.push(progression_flag);
136        }
137
138        // Determine risk level
139        let risk_level = match score {
140            0..=25 => RiskLevel::Low,
141            26..=50 => RiskLevel::Medium,
142            51..=75 => RiskLevel::High,
143            _ => RiskLevel::Critical,
144        };
145
146        // Add to history
147        self.add_to_history(transaction.clone());
148
149        FraudScore {
150            score: score.min(100),
151            risk_level,
152            flags,
153        }
154    }
155
156    fn check_velocity(&self, transaction: &Transaction) -> Option<FraudFlag> {
157        let account = transaction.from_account.as_ref()?;
158        if let Some(history) = self.history.get(account) {
159            let one_hour_ago = transaction.timestamp - chrono::Duration::hours(1);
160            let recent = history
161                .iter()
162                .filter(|t| t.timestamp > one_hour_ago)
163                .count();
164
165            if recent >= self.thresholds.max_transactions_per_hour {
166                return Some(FraudFlag {
167                    flag_type: FraudFlagType::VelocityExceeded,
168                    description: format!(
169                        "{} transactions in last hour (limit: {})",
170                        recent, self.thresholds.max_transactions_per_hour
171                    ),
172                    severity: 25,
173                });
174            }
175        }
176        None
177    }
178
179    fn check_unusual_amount(&self, transaction: &Transaction) -> Option<FraudFlag> {
180        if transaction.amount > self.thresholds.max_amount {
181            return Some(FraudFlag {
182                flag_type: FraudFlagType::UnusualAmount,
183                description: format!(
184                    "Amount {} exceeds threshold {}",
185                    transaction.amount, self.thresholds.max_amount
186                ),
187                severity: 30,
188            });
189        }
190
191        // Check against historical average
192        if let Some(account) = &transaction.from_account {
193            if let Some(history) = self.history.get(account) {
194                if !history.is_empty() {
195                    let avg: f64 =
196                        history.iter().map(|t| t.amount).sum::<f64>() / history.len() as f64;
197                    if transaction.amount > avg * 5.0 {
198                        return Some(FraudFlag {
199                            flag_type: FraudFlagType::UnusualAmount,
200                            description: format!(
201                                "Amount {} is 5x higher than average {}",
202                                transaction.amount, avg
203                            ),
204                            severity: 20,
205                        });
206                    }
207                }
208            }
209        }
210        None
211    }
212
213    fn check_round_amount(&self, transaction: &Transaction) -> Option<FraudFlag> {
214        if transaction.amount >= self.thresholds.round_amount_threshold
215            && transaction.amount % 1000.0 == 0.0
216        {
217            return Some(FraudFlag {
218                flag_type: FraudFlagType::RoundAmount,
219                description: format!(
220                    "Suspicious round amount: {} (potential structuring)",
221                    transaction.amount
222                ),
223                severity: 15,
224            });
225        }
226        None
227    }
228
229    fn check_high_risk_country(&self, transaction: &Transaction) -> Option<FraudFlag> {
230        if let Some(ref metadata) = transaction.metadata {
231            if let Some(country) = metadata.get("country") {
232                if self.high_risk_countries.contains(country) {
233                    return Some(FraudFlag {
234                        flag_type: FraudFlagType::HighRiskCountry,
235                        description: format!("Transaction from high-risk country: {}", country),
236                        severity: 35,
237                    });
238                }
239            }
240        }
241        None
242    }
243
244    fn check_rapid_succession(&self, transaction: &Transaction) -> Option<FraudFlag> {
245        if let Some(account) = &transaction.from_account {
246            if let Some(history) = self.history.get(account) {
247                if let Some(last) = history.last() {
248                    let time_diff = transaction.timestamp - last.timestamp;
249                    if time_diff < chrono::Duration::seconds(30) {
250                        return Some(FraudFlag {
251                            flag_type: FraudFlagType::RapidSuccession,
252                            description: format!(
253                                "Transaction within {} seconds of previous",
254                                time_diff.num_seconds()
255                            ),
256                            severity: 10,
257                        });
258                    }
259                }
260            }
261        }
262        None
263    }
264
265    fn check_amount_progression(&self, transaction: &Transaction) -> Option<FraudFlag> {
266        if let Some(account) = &transaction.from_account {
267            if let Some(history) = self.history.get(account) {
268                if history.len() >= 3 {
269                    let last_three: Vec<f64> =
270                        history.iter().rev().take(3).map(|t| t.amount).collect();
271                    // Check if amounts are incrementing (potential testing pattern)
272                    if last_three.windows(2).all(|w| w[0] < w[1]) {
273                        return Some(FraudFlag {
274                            flag_type: FraudFlagType::AmountProgression,
275                            description:
276                                "Incrementing amounts detected (potential account testing)"
277                                    .to_string(),
278                            severity: 20,
279                        });
280                    }
281                }
282            }
283        }
284        None
285    }
286
287    fn add_to_history(&mut self, transaction: Transaction) {
288        if let Some(account) = transaction.from_account.clone() {
289            self.history
290                .entry(account)
291                .or_default()
292                .push(transaction);
293        }
294    }
295
296    /// Clear old history (keep last 24 hours)
297    pub fn cleanup_history(&mut self) {
298        let now = chrono::Utc::now();
299        let cutoff = now - chrono::Duration::hours(24);
300
301        for transactions in self.history.values_mut() {
302            transactions.retain(|t| t.timestamp > cutoff);
303        }
304
305        // Remove empty entries
306        self.history.retain(|_, v| !v.is_empty());
307    }
308
309    /// Get transaction count for account
310    pub fn get_transaction_count(&self, account: &str) -> usize {
311        self.history.get(account).map_or(0, |h| h.len())
312    }
313
314    /// Get daily total for account
315    pub fn get_daily_total(&self, account: &str) -> f64 {
316        if let Some(history) = self.history.get(account) {
317            let one_day_ago = chrono::Utc::now() - chrono::Duration::hours(24);
318            history
319                .iter()
320                .filter(|t| t.timestamp > one_day_ago)
321                .map(|t| t.amount)
322                .sum()
323        } else {
324            0.0
325        }
326    }
327}
328
329impl Default for FraudDetector {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use chrono::Utc;
339
340    fn create_test_transaction(amount: f64) -> Transaction {
341        Transaction {
342            transaction_id: "TXN-001".to_string(),
343            from_account: Some("ACC-123".to_string()),
344            to_account: Some("ACC-456".to_string()),
345            amount,
346            currency: "USD".to_string(),
347            timestamp: Utc::now(),
348            transaction_type: crate::TransactionType::Transfer,
349            user_id: "USER-001".to_string(),
350            metadata: None,
351        }
352    }
353
354    #[test]
355    fn test_low_risk_transaction() {
356        let mut detector = FraudDetector::new();
357        let txn = create_test_transaction(100.0);
358        let score = detector.calculate_fraud_score(&txn);
359
360        assert_eq!(score.risk_level, RiskLevel::Low);
361        assert!(score.flags.is_empty());
362    }
363
364    #[test]
365    fn test_high_amount_detection() {
366        let mut detector = FraudDetector::new();
367        let txn = create_test_transaction(60000.0);
368        let score = detector.calculate_fraud_score(&txn);
369
370        assert!(score.score > 0);
371        assert!(score
372            .flags
373            .iter()
374            .any(|f| f.flag_type == FraudFlagType::UnusualAmount));
375    }
376
377    #[test]
378    fn test_round_amount_detection() {
379        let mut detector = FraudDetector::new();
380        let txn = create_test_transaction(15000.0);
381        let score = detector.calculate_fraud_score(&txn);
382
383        assert!(score
384            .flags
385            .iter()
386            .any(|f| f.flag_type == FraudFlagType::RoundAmount));
387    }
388
389    #[test]
390    fn test_velocity_detection() {
391        let mut detector = FraudDetector::with_thresholds(FraudThresholds {
392            max_transactions_per_hour: 2,
393            ..Default::default()
394        });
395
396        // First transaction - should pass
397        let mut txn1 = create_test_transaction(100.0);
398        txn1.transaction_id = "TXN-VEL-001".to_string();
399        let score1 = detector.calculate_fraud_score(&txn1);
400        assert!(score1
401            .flags
402            .iter()
403            .all(|f| f.flag_type != FraudFlagType::VelocityExceeded));
404
405        // Second transaction - should pass (at limit but not exceeded)
406        let mut txn2 = create_test_transaction(100.0);
407        txn2.transaction_id = "TXN-VEL-002".to_string();
408        let score2 = detector.calculate_fraud_score(&txn2);
409        assert!(score2
410            .flags
411            .iter()
412            .all(|f| f.flag_type != FraudFlagType::VelocityExceeded));
413
414        // Third transaction - should trigger velocity flag (exceeds limit of 2)
415        let mut txn3 = create_test_transaction(100.0);
416        txn3.transaction_id = "TXN-VEL-003".to_string();
417        let score3 = detector.calculate_fraud_score(&txn3);
418        assert!(score3
419            .flags
420            .iter()
421            .any(|f| f.flag_type == FraudFlagType::VelocityExceeded));
422    }
423
424    #[test]
425    fn test_high_risk_country() {
426        let mut detector = FraudDetector::new();
427        let mut txn = create_test_transaction(1000.0);
428        let mut metadata = std::collections::HashMap::new();
429        metadata.insert("country".to_string(), "IR".to_string());
430        txn.metadata = Some(metadata);
431
432        let score = detector.calculate_fraud_score(&txn);
433        assert!(score
434            .flags
435            .iter()
436            .any(|f| f.flag_type == FraudFlagType::HighRiskCountry));
437    }
438
439    #[test]
440    fn test_history_cleanup() {
441        let mut detector = FraudDetector::new();
442        for _ in 0..10 {
443            let txn = create_test_transaction(100.0);
444            detector.calculate_fraud_score(&txn);
445        }
446
447        assert_eq!(detector.get_transaction_count("ACC-123"), 10);
448        detector.cleanup_history();
449        // Should still have transactions (they're recent)
450        assert!(detector.get_transaction_count("ACC-123") > 0);
451    }
452
453    #[test]
454    fn test_daily_total() {
455        let mut detector = FraudDetector::new();
456        detector.calculate_fraud_score(&create_test_transaction(1000.0));
457        detector.calculate_fraud_score(&create_test_transaction(2000.0));
458        detector.calculate_fraud_score(&create_test_transaction(1500.0));
459
460        let total = detector.get_daily_total("ACC-123");
461        assert_eq!(total, 4500.0);
462    }
463}