rust_transaction_validator/
aml_compliance.rs1use crate::Transaction;
4use serde::{Deserialize, Serialize};
5
6pub struct AMLChecker {
8 thresholds: AMLThresholds,
10 sanctioned_entities: Vec<String>,
12}
13
14#[derive(Debug, Clone)]
16pub struct AMLThresholds {
17 pub ctr_threshold: f64,
19 pub sar_threshold: f64,
21 pub structuring_threshold: f64,
23}
24
25impl Default for AMLThresholds {
26 fn default() -> Self {
27 Self {
28 ctr_threshold: 10000.0, sar_threshold: 5000.0, structuring_threshold: 9500.0, }
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AMLResult {
38 pub compliant: bool,
39 pub requires_ctr: bool,
40 pub requires_sar: bool,
41 pub red_flags: Vec<AMLRedFlag>,
42 pub risk_score: u8,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct AMLRedFlag {
48 pub flag_type: RedFlagType,
49 pub description: String,
50 pub severity: AlertSeverity,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub enum RedFlagType {
55 PotentialStructuring,
56 HighValueTransaction,
57 SanctionedEntity,
58 RapidMovement,
59 UnusualPattern,
60 CashIntensive,
61 CrossBorder,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub enum AlertSeverity {
66 Low,
67 Medium,
68 High,
69 Critical,
70}
71
72impl AMLChecker {
73 pub fn new() -> Self {
75 Self {
76 thresholds: AMLThresholds::default(),
77 sanctioned_entities: vec![
78 "OFAC-SANCTIONED-001".to_string(),
79 "SANCTIONED-ENTITY-002".to_string(),
80 ],
81 }
82 }
83
84 pub fn check_compliance(&self, transaction: &Transaction) -> AMLResult {
86 let mut red_flags = Vec::new();
87 let mut risk_score = 0u8;
88
89 let requires_ctr = transaction.amount >= self.thresholds.ctr_threshold;
91
92 let mut requires_sar = false;
94
95 if self.is_potential_structuring(transaction) {
97 red_flags.push(AMLRedFlag {
98 flag_type: RedFlagType::PotentialStructuring,
99 description: format!(
100 "Amount {} is just below CTR threshold (potential structuring)",
101 transaction.amount
102 ),
103 severity: AlertSeverity::High,
104 });
105 risk_score += 35;
106 requires_sar = true;
107 }
108
109 if transaction.amount >= self.thresholds.ctr_threshold {
111 red_flags.push(AMLRedFlag {
112 flag_type: RedFlagType::HighValueTransaction,
113 description: format!(
114 "High value transaction: {} (CTR required)",
115 transaction.amount
116 ),
117 severity: AlertSeverity::Medium,
118 });
119 risk_score += 15;
120 }
121
122 let from_sanctioned = transaction
124 .from_account
125 .as_deref()
126 .map(|a| self.is_sanctioned_entity(a))
127 .unwrap_or(false);
128 let to_sanctioned = transaction
129 .to_account
130 .as_deref()
131 .map(|a| self.is_sanctioned_entity(a))
132 .unwrap_or(false);
133 if from_sanctioned || to_sanctioned {
134 red_flags.push(AMLRedFlag {
135 flag_type: RedFlagType::SanctionedEntity,
136 description: "Transaction involves sanctioned entity".to_string(),
137 severity: AlertSeverity::Critical,
138 });
139 risk_score = 100;
140 requires_sar = true;
141 }
142
143 if let Some(ref metadata) = transaction.metadata {
145 if metadata
146 .get("cross_border")
147 .map(|v| v == "true")
148 .unwrap_or(false)
149 {
150 red_flags.push(AMLRedFlag {
151 flag_type: RedFlagType::CrossBorder,
152 description: "Cross-border transaction requires additional due diligence"
153 .to_string(),
154 severity: AlertSeverity::Medium,
155 });
156 risk_score += 20;
157 }
158 }
159
160 if matches!(
162 transaction.transaction_type,
163 crate::TransactionType::Deposit | crate::TransactionType::Withdrawal
164 ) && transaction.amount >= 5000.0
165 {
166 red_flags.push(AMLRedFlag {
167 flag_type: RedFlagType::CashIntensive,
168 description: format!(
169 "Large cash {} of {}",
170 transaction.transaction_type, transaction.amount
171 ),
172 severity: AlertSeverity::High,
173 });
174 risk_score += 25;
175 }
176
177 AMLResult {
178 compliant: risk_score < 75,
179 requires_ctr,
180 requires_sar,
181 red_flags,
182 risk_score: risk_score.min(100),
183 }
184 }
185
186 fn is_potential_structuring(&self, transaction: &Transaction) -> bool {
187 transaction.amount >= self.thresholds.structuring_threshold
188 && transaction.amount < self.thresholds.ctr_threshold
189 }
190
191 fn is_sanctioned_entity(&self, entity: &str) -> bool {
192 self.sanctioned_entities.iter().any(|s| entity.contains(s))
193 }
194
195 pub fn add_sanctioned_entity(&mut self, entity: String) {
197 if !self.sanctioned_entities.contains(&entity) {
198 self.sanctioned_entities.push(entity);
199 }
200 }
201
202 pub fn check_sanctions_list(&self, entity: &str) -> bool {
204 self.is_sanctioned_entity(entity)
205 }
206}
207
208impl Default for AMLChecker {
209 fn default() -> Self {
210 Self::new()
211 }
212}
213
214pub struct KYCValidator;
216
217impl KYCValidator {
218 pub fn validate_customer_data(customer_data: &serde_json::Value) -> KYCValidationResult {
220 let mut missing_fields = Vec::new();
221 let mut warnings = Vec::new();
222
223 let required = [
225 "full_name",
226 "date_of_birth",
227 "address",
228 "id_number",
229 "id_type",
230 ];
231
232 for field in &required {
233 if customer_data.get(field).is_none() {
234 missing_fields.push(field.to_string());
235 }
236 }
237
238 if let Some(country) = customer_data.get("country").and_then(|v| v.as_str()) {
240 if Self::is_high_risk_jurisdiction(country) {
241 warnings.push(
242 "Customer from high-risk jurisdiction - Enhanced Due Diligence required"
243 .to_string(),
244 );
245 }
246 }
247
248 if let Some(pep) = customer_data
249 .get("politically_exposed_person")
250 .and_then(|v| v.as_bool())
251 {
252 if pep {
253 warnings.push(
254 "Politically Exposed Person - Enhanced Due Diligence required".to_string(),
255 );
256 }
257 }
258
259 let requires_enhanced_dd = !warnings.is_empty();
260 KYCValidationResult {
261 valid: missing_fields.is_empty(),
262 missing_fields,
263 warnings,
264 requires_enhanced_dd,
265 }
266 }
267
268 fn is_high_risk_jurisdiction(country: &str) -> bool {
269 matches!(country, "KP" | "IR" | "SY" | "CU" | "SD")
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct KYCValidationResult {
277 pub valid: bool,
278 pub missing_fields: Vec<String>,
279 pub warnings: Vec<String>,
280 pub requires_enhanced_dd: bool,
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use chrono::Utc;
287
288 fn create_test_transaction(amount: f64, txn_type: crate::TransactionType) -> Transaction {
289 Transaction {
290 transaction_id: "TXN-001".to_string(),
291 from_account: Some("ACC-123".to_string()),
292 to_account: Some("ACC-456".to_string()),
293 amount,
294 currency: "USD".to_string(),
295 timestamp: Utc::now(),
296 transaction_type: txn_type,
297 user_id: "USER-001".to_string(),
298 metadata: None,
299 }
300 }
301
302 #[test]
303 fn test_ctr_requirement() {
304 let checker = AMLChecker::new();
305 let txn = create_test_transaction(15000.0, crate::TransactionType::Transfer);
306 let result = checker.check_compliance(&txn);
307
308 assert!(result.requires_ctr);
309 assert!(result
310 .red_flags
311 .iter()
312 .any(|f| f.flag_type == RedFlagType::HighValueTransaction));
313 }
314
315 #[test]
316 fn test_structuring_detection() {
317 let checker = AMLChecker::new();
318 let txn = create_test_transaction(9800.0, crate::TransactionType::Transfer);
319 let result = checker.check_compliance(&txn);
320
321 assert!(result.requires_sar);
322 assert!(result
323 .red_flags
324 .iter()
325 .any(|f| f.flag_type == RedFlagType::PotentialStructuring));
326 }
327
328 #[test]
329 fn test_sanctioned_entity() {
330 let checker = AMLChecker::new();
331 let mut txn = create_test_transaction(1000.0, crate::TransactionType::Transfer);
332 txn.from_account = Some("OFAC-SANCTIONED-001".to_string());
333
334 let result = checker.check_compliance(&txn);
335
336 assert!(!result.compliant);
337 assert_eq!(result.risk_score, 100);
338 assert!(result
339 .red_flags
340 .iter()
341 .any(|f| f.flag_type == RedFlagType::SanctionedEntity));
342 }
343
344 #[test]
345 fn test_cash_intensive() {
346 let checker = AMLChecker::new();
347 let txn = create_test_transaction(8000.0, crate::TransactionType::Deposit);
348 let result = checker.check_compliance(&txn);
349
350 assert!(result
351 .red_flags
352 .iter()
353 .any(|f| f.flag_type == RedFlagType::CashIntensive));
354 }
355
356 #[test]
357 fn test_cross_border() {
358 let checker = AMLChecker::new();
359 let mut txn = create_test_transaction(5000.0, crate::TransactionType::Transfer);
360 let mut metadata = std::collections::HashMap::new();
361 metadata.insert("cross_border".to_string(), "true".to_string());
362 txn.metadata = Some(metadata);
363
364 let result = checker.check_compliance(&txn);
365
366 assert!(result
367 .red_flags
368 .iter()
369 .any(|f| f.flag_type == RedFlagType::CrossBorder));
370 }
371
372 #[test]
373 fn test_kyc_validation() {
374 let complete_data = serde_json::json!({
375 "full_name": "John Doe",
376 "date_of_birth": "1990-01-01",
377 "address": "123 Main St",
378 "id_number": "123456789",
379 "id_type": "passport",
380 "country": "US"
381 });
382
383 let result = KYCValidator::validate_customer_data(&complete_data);
384 assert!(result.valid);
385 assert!(result.missing_fields.is_empty());
386 }
387
388 #[test]
389 fn test_kyc_missing_fields() {
390 let incomplete_data = serde_json::json!({
391 "full_name": "John Doe"
392 });
393
394 let result = KYCValidator::validate_customer_data(&incomplete_data);
395 assert!(!result.valid);
396 assert!(!result.missing_fields.is_empty());
397 }
398
399 #[test]
400 fn test_kyc_enhanced_dd() {
401 let pep_data = serde_json::json!({
402 "full_name": "John Doe",
403 "date_of_birth": "1990-01-01",
404 "address": "123 Main St",
405 "id_number": "123456789",
406 "id_type": "passport",
407 "politically_exposed_person": true
408 });
409
410 let result = KYCValidator::validate_customer_data(&pep_data);
411 assert!(result.requires_enhanced_dd);
412 assert!(!result.warnings.is_empty());
413 }
414}