rust_transaction_validator/
fraud_patterns.rs1use crate::Transaction;
4use std::collections::HashMap;
5
6pub struct FraudDetector {
8 history: HashMap<String, Vec<Transaction>>,
10 high_risk_countries: Vec<String>,
12 thresholds: FraudThresholds,
14}
15
16#[derive(Debug, Clone)]
18pub struct FraudThresholds {
19 pub max_amount: f64,
21 pub max_transactions_per_hour: usize,
23 pub max_daily_total: f64,
25 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#[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, Medium, High, Critical, }
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 pub fn new() -> Self {
79 Self {
80 history: HashMap::new(),
81 high_risk_countries: vec![
82 "KP".to_string(), "IR".to_string(), "SY".to_string(), ],
86 thresholds: FraudThresholds::default(),
87 }
88 }
89
90 pub fn with_thresholds(thresholds: FraudThresholds) -> Self {
92 let mut detector = Self::new();
93 detector.thresholds = thresholds;
94 detector
95 }
96
97 pub fn calculate_fraud_score(&mut self, transaction: &Transaction) -> FraudScore {
99 let mut score = 0u8;
100 let mut flags = Vec::new();
101
102 if let Some(velocity_flag) = self.check_velocity(transaction) {
104 score += velocity_flag.severity;
105 flags.push(velocity_flag);
106 }
107
108 if let Some(amount_flag) = self.check_unusual_amount(transaction) {
110 score += amount_flag.severity;
111 flags.push(amount_flag);
112 }
113
114 if let Some(round_flag) = self.check_round_amount(transaction) {
116 score += round_flag.severity;
117 flags.push(round_flag);
118 }
119
120 if let Some(country_flag) = self.check_high_risk_country(transaction) {
122 score += country_flag.severity;
123 flags.push(country_flag);
124 }
125
126 if let Some(rapid_flag) = self.check_rapid_succession(transaction) {
128 score += rapid_flag.severity;
129 flags.push(rapid_flag);
130 }
131
132 if let Some(progression_flag) = self.check_amount_progression(transaction) {
134 score += progression_flag.severity;
135 flags.push(progression_flag);
136 }
137
138 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 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 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 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 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 self.history.retain(|_, v| !v.is_empty());
307 }
308
309 pub fn get_transaction_count(&self, account: &str) -> usize {
311 self.history.get(account).map_or(0, |h| h.len())
312 }
313
314 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 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 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 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 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}