rust_transaction_validator/
sanctions.rs1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
11pub enum SanctionsList {
12 OFAC, EU, UN, UKOFSI, Custom(String), }
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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub enum MatchType {
34 Exact,
35 Partial,
36 Fuzzy,
37 Alias,
38}
39
40#[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 pub fn has_high_confidence_match(&self) -> bool {
53 self.matches.iter().any(|m| m.confidence >= 0.9)
54 }
55
56 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 pub fn matches_above_threshold(&self, threshold: f32) -> Vec<&SanctionsMatch> {
65 self.matches.iter().filter(|m| m.confidence >= threshold).collect()
66 }
67}
68
69#[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#[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
92pub struct SanctionsScreener {
94 entities: Vec<SanctionedEntity>,
95 enabled_lists: HashSet<SanctionsList>,
96 fuzzy_threshold: f32,
97}
98
99impl SanctionsScreener {
100 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 fn load_default_entries(&mut self) {
116 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 pub fn enable_list(&mut self, list: SanctionsList) {
149 self.enabled_lists.insert(list);
150 }
151
152 pub fn disable_list(&mut self, list: &SanctionsList) {
154 self.enabled_lists.remove(list);
155 }
156
157 pub fn set_fuzzy_threshold(&mut self, threshold: f32) {
159 self.fuzzy_threshold = threshold.clamp(0.0, 1.0);
160 }
161
162 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 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 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 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 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 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 pub fn screen_batch(&self, names: &[&str]) -> Vec<SanctionsResult> {
250 names.iter().map(|name| self.screen(name)).collect()
251 }
252
253 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 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 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 (char_similarity * 0.4) + (word_similarity * 0.6)
284 }
285
286 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 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); assert!(results[2].is_match); }
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 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); let result = screener.screen("SANCTIONED ENTTY ONE"); 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}