rust_transaction_validator/
sanctions.rs

1//! Sanctions screening module for transaction validation v2.0
2//!
3//! Provides real-time sanctions list screening against OFAC, EU, and UN lists.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9/// Sanctions list source
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
11pub enum SanctionsList {
12    OFAC,           // US Office of Foreign Assets Control
13    EU,             // European Union
14    UN,             // United Nations
15    UKOFSI,         // UK Office of Financial Sanctions Implementation
16    Custom(String), // Custom list
17}
18
19impl SanctionsList {
20    pub fn name(&self) -> &str {
21        match self {
22            SanctionsList::OFAC => "OFAC SDN",
23            SanctionsList::EU => "EU Consolidated",
24            SanctionsList::UN => "UN Security Council",
25            SanctionsList::UKOFSI => "UK OFSI",
26            SanctionsList::Custom(name) => name,
27        }
28    }
29}
30
31/// Sanctions match type
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub enum MatchType {
34    Exact,
35    Partial,
36    Fuzzy,
37    Alias,
38}
39
40/// Sanctions screening result
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SanctionsResult {
43    pub screened_value: String,
44    pub is_match: bool,
45    pub matches: Vec<SanctionsMatch>,
46    pub screening_time: DateTime<Utc>,
47    pub lists_checked: Vec<SanctionsList>,
48}
49
50impl SanctionsResult {
51    /// Check if there are any high-confidence matches
52    pub fn has_high_confidence_match(&self) -> bool {
53        self.matches.iter().any(|m| m.confidence >= 0.9)
54    }
55
56    /// Get highest confidence match
57    pub fn highest_confidence(&self) -> Option<&SanctionsMatch> {
58        self.matches.iter().max_by(|a, b| {
59            a.confidence.partial_cmp(&b.confidence).unwrap_or(std::cmp::Ordering::Equal)
60        })
61    }
62
63    /// Get matches above threshold
64    pub fn matches_above_threshold(&self, threshold: f32) -> Vec<&SanctionsMatch> {
65        self.matches.iter().filter(|m| m.confidence >= threshold).collect()
66    }
67}
68
69/// Individual sanctions match
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SanctionsMatch {
72    pub matched_name: String,
73    pub list: SanctionsList,
74    pub match_type: MatchType,
75    pub confidence: f32,
76    pub entry_id: String,
77    pub program: Option<String>,
78    pub country: Option<String>,
79}
80
81/// Sanctioned entity for the database
82#[derive(Debug, Clone)]
83struct SanctionedEntity {
84    id: String,
85    name: String,
86    aliases: Vec<String>,
87    list: SanctionsList,
88    program: Option<String>,
89    country: Option<String>,
90}
91
92/// Sanctions screener
93pub struct SanctionsScreener {
94    entities: Vec<SanctionedEntity>,
95    enabled_lists: HashSet<SanctionsList>,
96    fuzzy_threshold: f32,
97}
98
99impl SanctionsScreener {
100    /// Create a new sanctions screener
101    pub fn new() -> Self {
102        let mut screener = Self {
103            entities: Vec::new(),
104            enabled_lists: HashSet::new(),
105            fuzzy_threshold: 0.85,
106        };
107        screener.enabled_lists.insert(SanctionsList::OFAC);
108        screener.enabled_lists.insert(SanctionsList::EU);
109        screener.enabled_lists.insert(SanctionsList::UN);
110        screener.load_default_entries();
111        screener
112    }
113
114    /// Load default sanctioned entities (simplified for demonstration)
115    fn load_default_entries(&mut self) {
116        // Note: In production, this would load from actual OFAC/EU/UN data
117        // These are fictional entries for demonstration purposes
118
119        self.entities.push(SanctionedEntity {
120            id: "OFAC-001".to_string(),
121            name: "SANCTIONED ENTITY ONE".to_string(),
122            aliases: vec!["ENTITY ONE".to_string(), "E1 LTD".to_string()],
123            list: SanctionsList::OFAC,
124            program: Some("SDGT".to_string()),
125            country: Some("XX".to_string()),
126        });
127
128        self.entities.push(SanctionedEntity {
129            id: "EU-001".to_string(),
130            name: "RESTRICTED COMPANY EU".to_string(),
131            aliases: vec!["RC EU".to_string()],
132            list: SanctionsList::EU,
133            program: Some("COUNCIL REGULATION".to_string()),
134            country: Some("YY".to_string()),
135        });
136
137        self.entities.push(SanctionedEntity {
138            id: "UN-001".to_string(),
139            name: "UN LISTED ORGANIZATION".to_string(),
140            aliases: vec!["ULO".to_string()],
141            list: SanctionsList::UN,
142            program: Some("1267".to_string()),
143            country: None,
144        });
145    }
146
147    /// Enable a sanctions list
148    pub fn enable_list(&mut self, list: SanctionsList) {
149        self.enabled_lists.insert(list);
150    }
151
152    /// Disable a sanctions list
153    pub fn disable_list(&mut self, list: &SanctionsList) {
154        self.enabled_lists.remove(list);
155    }
156
157    /// Set fuzzy matching threshold
158    pub fn set_fuzzy_threshold(&mut self, threshold: f32) {
159        self.fuzzy_threshold = threshold.clamp(0.0, 1.0);
160    }
161
162    /// Screen a name against sanctions lists
163    pub fn screen(&self, name: &str) -> SanctionsResult {
164        let name_upper = name.to_uppercase();
165        let mut matches = Vec::new();
166        let lists_checked: Vec<SanctionsList> = self.enabled_lists.iter().cloned().collect();
167
168        for entity in &self.entities {
169            if !self.enabled_lists.contains(&entity.list) {
170                continue;
171            }
172
173            // Exact match on primary name
174            if entity.name == name_upper {
175                matches.push(SanctionsMatch {
176                    matched_name: entity.name.clone(),
177                    list: entity.list.clone(),
178                    match_type: MatchType::Exact,
179                    confidence: 1.0,
180                    entry_id: entity.id.clone(),
181                    program: entity.program.clone(),
182                    country: entity.country.clone(),
183                });
184                continue;
185            }
186
187            // Check aliases
188            for alias in &entity.aliases {
189                if alias.to_uppercase() == name_upper {
190                    matches.push(SanctionsMatch {
191                        matched_name: entity.name.clone(),
192                        list: entity.list.clone(),
193                        match_type: MatchType::Alias,
194                        confidence: 0.95,
195                        entry_id: entity.id.clone(),
196                        program: entity.program.clone(),
197                        country: entity.country.clone(),
198                    });
199                    break;
200                }
201            }
202
203            // Fuzzy matching
204            let similarity = self.calculate_similarity(&name_upper, &entity.name);
205            if similarity >= self.fuzzy_threshold {
206                matches.push(SanctionsMatch {
207                    matched_name: entity.name.clone(),
208                    list: entity.list.clone(),
209                    match_type: MatchType::Fuzzy,
210                    confidence: similarity,
211                    entry_id: entity.id.clone(),
212                    program: entity.program.clone(),
213                    country: entity.country.clone(),
214                });
215            }
216
217            // Partial match (contains)
218            if entity.name.contains(&name_upper) || name_upper.contains(&entity.name) {
219                let partial_conf = 0.7 + (0.2 * (name_upper.len().min(entity.name.len()) as f32
220                    / name_upper.len().max(entity.name.len()) as f32));
221
222                if !matches.iter().any(|m| m.entry_id == entity.id) {
223                    matches.push(SanctionsMatch {
224                        matched_name: entity.name.clone(),
225                        list: entity.list.clone(),
226                        match_type: MatchType::Partial,
227                        confidence: partial_conf,
228                        entry_id: entity.id.clone(),
229                        program: entity.program.clone(),
230                        country: entity.country.clone(),
231                    });
232                }
233            }
234        }
235
236        // Sort by confidence
237        matches.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
238
239        SanctionsResult {
240            screened_value: name.to_string(),
241            is_match: !matches.is_empty(),
242            matches,
243            screening_time: Utc::now(),
244            lists_checked,
245        }
246    }
247
248    /// Screen multiple names in batch
249    pub fn screen_batch(&self, names: &[&str]) -> Vec<SanctionsResult> {
250        names.iter().map(|name| self.screen(name)).collect()
251    }
252
253    /// Calculate string similarity using Levenshtein-based metric
254    fn calculate_similarity(&self, s1: &str, s2: &str) -> f32 {
255        if s1.is_empty() || s2.is_empty() {
256            return 0.0;
257        }
258
259        let len1 = s1.len();
260        let len2 = s2.len();
261        let max_len = len1.max(len2);
262
263        // Simple character-based similarity
264        let common_chars: usize = s1.chars()
265            .filter(|c| s2.contains(*c))
266            .count();
267
268        let char_similarity = common_chars as f32 / max_len as f32;
269
270        // Word-based similarity
271        let words1: HashSet<&str> = s1.split_whitespace().collect();
272        let words2: HashSet<&str> = s2.split_whitespace().collect();
273        let common_words = words1.intersection(&words2).count();
274        let total_words = words1.union(&words2).count();
275
276        let word_similarity = if total_words > 0 {
277            common_words as f32 / total_words as f32
278        } else {
279            0.0
280        };
281
282        // Weighted combination
283        (char_similarity * 0.4) + (word_similarity * 0.6)
284    }
285
286    /// Add a custom sanctioned entity
287    pub fn add_entity(&mut self, name: &str, aliases: Vec<String>, list: SanctionsList) {
288        let id = format!("{}-{}", list.name(), self.entities.len());
289        self.entities.push(SanctionedEntity {
290            id,
291            name: name.to_uppercase(),
292            aliases: aliases.into_iter().map(|a| a.to_uppercase()).collect(),
293            list,
294            program: None,
295            country: None,
296        });
297    }
298}
299
300impl Default for SanctionsScreener {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_exact_match() {
312        let screener = SanctionsScreener::new();
313        let result = screener.screen("SANCTIONED ENTITY ONE");
314
315        assert!(result.is_match);
316        assert!(!result.matches.is_empty());
317        assert_eq!(result.matches[0].match_type, MatchType::Exact);
318        assert_eq!(result.matches[0].confidence, 1.0);
319    }
320
321    #[test]
322    fn test_alias_match() {
323        let screener = SanctionsScreener::new();
324        let result = screener.screen("ENTITY ONE");
325
326        assert!(result.is_match);
327        assert!(result.matches.iter().any(|m| m.match_type == MatchType::Alias));
328    }
329
330    #[test]
331    fn test_no_match() {
332        let screener = SanctionsScreener::new();
333        let result = screener.screen("LEGITIMATE COMPANY XYZ");
334
335        // Should have no high-confidence matches
336        assert!(!result.has_high_confidence_match());
337    }
338
339    #[test]
340    fn test_batch_screening() {
341        let screener = SanctionsScreener::new();
342        let names = vec!["SANCTIONED ENTITY ONE", "NORMAL COMPANY", "ENTITY ONE"];
343        let results = screener.screen_batch(&names);
344
345        assert_eq!(results.len(), 3);
346        assert!(results[0].is_match); // Exact match
347        assert!(results[2].is_match); // Alias match
348    }
349
350    #[test]
351    fn test_custom_entity() {
352        let mut screener = SanctionsScreener::new();
353        screener.add_entity(
354            "CUSTOM BAD ACTOR",
355            vec!["CBA".to_string(), "BAD ACTOR CO".to_string()],
356            SanctionsList::Custom("INTERNAL".to_string()),
357        );
358        screener.enable_list(SanctionsList::Custom("INTERNAL".to_string()));
359
360        let result = screener.screen("CUSTOM BAD ACTOR");
361        assert!(result.is_match);
362    }
363
364    #[test]
365    fn test_list_filtering() {
366        let mut screener = SanctionsScreener::new();
367        screener.disable_list(&SanctionsList::OFAC);
368
369        let result = screener.screen("SANCTIONED ENTITY ONE");
370        // OFAC entry should not match since we disabled OFAC
371        assert!(!result.lists_checked.contains(&SanctionsList::OFAC));
372    }
373
374    #[test]
375    fn test_fuzzy_threshold() {
376        let mut screener = SanctionsScreener::new();
377        screener.set_fuzzy_threshold(0.95); // Very strict
378
379        let result = screener.screen("SANCTIONED ENTTY ONE"); // Typo
380        // Should have lower confidence due to typo
381        if result.is_match {
382            assert!(result.matches[0].confidence < 1.0);
383        }
384    }
385
386    #[test]
387    fn test_highest_confidence() {
388        let screener = SanctionsScreener::new();
389        let result = screener.screen("SANCTIONED ENTITY ONE");
390
391        let highest = result.highest_confidence();
392        assert!(highest.is_some());
393        assert_eq!(highest.unwrap().confidence, 1.0);
394    }
395}