1use chrono::NaiveDate;
4use datasynth_core::models::banking::{AmlTypology, LaunderingStage, Sophistication};
5use rust_decimal::Decimal;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AmlScenario {
14 pub scenario_id: String,
16 pub typology: AmlTypology,
18 pub secondary_typologies: Vec<AmlTypology>,
20 pub stages: Vec<LaunderingStage>,
22 pub start_date: NaiveDate,
24 pub end_date: NaiveDate,
26 pub involved_customers: Vec<Uuid>,
28 pub involved_accounts: Vec<Uuid>,
30 pub involved_transactions: Vec<Uuid>,
32 #[serde(with = "rust_decimal::serde::str")]
34 pub total_amount: Decimal,
35 pub evasion_tactics: Vec<EvasionTactic>,
37 pub sophistication: Sophistication,
39 pub detectability: f64,
41 pub narrative: CaseNarrative,
43 pub expected_alerts: Vec<ExpectedAlert>,
45 pub was_successful: bool,
47}
48
49impl AmlScenario {
50 pub fn new(
52 scenario_id: &str,
53 typology: AmlTypology,
54 start_date: NaiveDate,
55 end_date: NaiveDate,
56 ) -> Self {
57 Self {
58 scenario_id: scenario_id.to_string(),
59 typology,
60 secondary_typologies: Vec::new(),
61 stages: Vec::new(),
62 start_date,
63 end_date,
64 involved_customers: Vec::new(),
65 involved_accounts: Vec::new(),
66 involved_transactions: Vec::new(),
67 total_amount: Decimal::ZERO,
68 evasion_tactics: Vec::new(),
69 sophistication: Sophistication::default(),
70 detectability: typology.severity() as f64 / 10.0,
71 narrative: CaseNarrative::default(),
72 expected_alerts: Vec::new(),
73 was_successful: true,
74 }
75 }
76
77 pub fn add_stage(&mut self, stage: LaunderingStage) {
79 if !self.stages.contains(&stage) {
80 self.stages.push(stage);
81 }
82 }
83
84 pub fn add_customer(&mut self, customer_id: Uuid) {
86 if !self.involved_customers.contains(&customer_id) {
87 self.involved_customers.push(customer_id);
88 }
89 }
90
91 pub fn add_account(&mut self, account_id: Uuid) {
93 if !self.involved_accounts.contains(&account_id) {
94 self.involved_accounts.push(account_id);
95 }
96 }
97
98 pub fn add_transaction(&mut self, transaction_id: Uuid, amount: Decimal) {
100 self.involved_transactions.push(transaction_id);
101 self.total_amount += amount;
102 }
103
104 pub fn add_evasion_tactic(&mut self, tactic: EvasionTactic) {
106 if !self.evasion_tactics.contains(&tactic) {
107 self.evasion_tactics.push(tactic);
108 self.detectability *= 1.0 / tactic.difficulty_modifier();
110 }
111 }
112
113 pub fn with_sophistication(mut self, sophistication: Sophistication) -> Self {
115 self.sophistication = sophistication;
116 self.detectability *= sophistication.detectability_modifier();
117 self
118 }
119
120 pub fn complexity_score(&self) -> u8 {
122 let mut score = 0.0;
123
124 score += (self.involved_customers.len().max(1) as f64).ln() * 10.0;
126
127 score += (self.involved_accounts.len().max(1) as f64).ln() * 5.0;
129
130 score += (self.involved_transactions.len().max(1) as f64).ln() * 3.0;
132
133 let duration = (self.end_date - self.start_date).num_days();
135 score += (duration as f64 / 30.0).min(10.0) * 3.0;
136
137 score += self.evasion_tactics.len() as f64 * 5.0;
139
140 score += match self.sophistication {
142 Sophistication::Basic => 0.0,
143 Sophistication::Standard => 10.0,
144 Sophistication::Professional => 20.0,
145 Sophistication::Advanced => 30.0,
146 Sophistication::StateLevel => 40.0,
147 };
148
149 score += self.stages.len() as f64 * 5.0;
151
152 score.min(100.0) as u8
153 }
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158pub struct CaseNarrative {
159 pub storyline: String,
161 pub evidence_points: Vec<String>,
163 pub violated_expectations: Vec<ViolatedExpectation>,
165 pub red_flags: Vec<RedFlag>,
167 pub recommendation: CaseRecommendation,
169 pub investigation_notes: Vec<String>,
171}
172
173impl CaseNarrative {
174 pub fn new(storyline: &str) -> Self {
176 Self {
177 storyline: storyline.to_string(),
178 ..Default::default()
179 }
180 }
181
182 pub fn add_evidence(&mut self, evidence: &str) {
184 self.evidence_points.push(evidence.to_string());
185 }
186
187 pub fn add_violated_expectation(&mut self, expectation: ViolatedExpectation) {
189 self.violated_expectations.push(expectation);
190 }
191
192 pub fn add_red_flag(&mut self, flag: RedFlag) {
194 self.red_flags.push(flag);
195 }
196
197 pub fn with_recommendation(mut self, recommendation: CaseRecommendation) -> Self {
199 self.recommendation = recommendation;
200 self
201 }
202
203 pub fn generate_text(&self) -> String {
205 let mut text = format!("## Case Summary\n\n{}\n\n", self.storyline);
206
207 if !self.evidence_points.is_empty() {
208 text.push_str("## Evidence Points\n\n");
209 for (i, point) in self.evidence_points.iter().enumerate() {
210 text.push_str(&format!("{}. {}\n", i + 1, point));
211 }
212 text.push('\n');
213 }
214
215 if !self.violated_expectations.is_empty() {
216 text.push_str("## Violated Expectations\n\n");
217 for ve in &self.violated_expectations {
218 text.push_str(&format!(
219 "- **{}**: Expected {}, Actual {}\n",
220 ve.expectation_type, ve.expected_value, ve.actual_value
221 ));
222 }
223 text.push('\n');
224 }
225
226 if !self.red_flags.is_empty() {
227 text.push_str("## Red Flags\n\n");
228 for flag in &self.red_flags {
229 text.push_str(&format!(
230 "- {} (Severity: {})\n",
231 flag.description, flag.severity
232 ));
233 }
234 text.push('\n');
235 }
236
237 text.push_str(&format!(
238 "## Recommendation\n\n{}\n",
239 self.recommendation.description()
240 ));
241
242 text
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct ViolatedExpectation {
249 pub expectation_type: String,
251 pub expected_value: String,
253 pub actual_value: String,
255 pub deviation_percentage: f64,
257}
258
259impl ViolatedExpectation {
260 pub fn new(expectation_type: &str, expected: &str, actual: &str, deviation: f64) -> Self {
262 Self {
263 expectation_type: expectation_type.to_string(),
264 expected_value: expected.to_string(),
265 actual_value: actual.to_string(),
266 deviation_percentage: deviation,
267 }
268 }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct RedFlag {
274 pub category: RedFlagCategory,
276 pub description: String,
278 pub severity: u8,
280 pub date_identified: NaiveDate,
282}
283
284impl RedFlag {
285 pub fn new(
287 category: RedFlagCategory,
288 description: &str,
289 severity: u8,
290 date: NaiveDate,
291 ) -> Self {
292 Self {
293 category,
294 description: description.to_string(),
295 severity,
296 date_identified: date,
297 }
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum RedFlagCategory {
305 ActivityPattern,
307 Geographic,
309 CustomerBehavior,
311 TransactionCharacteristic,
313 AccountCharacteristic,
315 ThirdParty,
317 Timing,
319 Documentation,
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
325#[serde(rename_all = "snake_case")]
326pub enum CaseRecommendation {
327 #[default]
329 CloseNoAction,
330 ContinueMonitoring,
332 EnhancedMonitoring,
334 EscalateCompliance,
336 FileSar,
338 CloseAccount,
340 ReportLawEnforcement,
342}
343
344impl CaseRecommendation {
345 pub fn description(&self) -> &'static str {
347 match self {
348 Self::CloseNoAction => "Close case - no suspicious activity identified",
349 Self::ContinueMonitoring => "Continue standard monitoring",
350 Self::EnhancedMonitoring => "Place customer under enhanced monitoring",
351 Self::EscalateCompliance => "Escalate to compliance officer for review",
352 Self::FileSar => "Escalate to SAR filing",
353 Self::CloseAccount => "Close account and file SAR",
354 Self::ReportLawEnforcement => "Report to law enforcement immediately",
355 }
356 }
357
358 pub fn severity(&self) -> u8 {
360 match self {
361 Self::CloseNoAction => 1,
362 Self::ContinueMonitoring => 1,
363 Self::EnhancedMonitoring => 2,
364 Self::EscalateCompliance => 3,
365 Self::FileSar => 4,
366 Self::CloseAccount => 4,
367 Self::ReportLawEnforcement => 5,
368 }
369 }
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct ExpectedAlert {
375 pub alert_type: String,
377 pub expected_date: NaiveDate,
379 pub triggering_transactions: Vec<Uuid>,
381 pub severity: AlertSeverity,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
387#[serde(rename_all = "snake_case")]
388pub enum AlertSeverity {
389 #[default]
391 Low,
392 Medium,
394 High,
396 Critical,
398}
399
400pub use datasynth_core::models::banking::EvasionTactic;
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_aml_scenario() {
409 let scenario = AmlScenario::new(
410 "SC-2024-001",
411 AmlTypology::Structuring,
412 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
413 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
414 );
415
416 assert_eq!(scenario.typology, AmlTypology::Structuring);
417 assert!(scenario.was_successful);
418 }
419
420 #[test]
421 fn test_case_narrative() {
422 let mut narrative = CaseNarrative::new(
423 "Subject conducted 12 cash deposits just below $10,000 threshold over 3 days.",
424 );
425 narrative.add_evidence("12 deposits ranging from $9,500 to $9,900");
426 narrative.add_evidence("All deposits at different branch locations");
427 narrative.add_violated_expectation(ViolatedExpectation::new(
428 "Monthly deposits",
429 "2",
430 "12",
431 500.0,
432 ));
433
434 let text = narrative.generate_text();
435 assert!(text.contains("12 cash deposits"));
436 assert!(text.contains("Evidence Points"));
437 }
438
439 #[test]
440 fn test_complexity_score() {
441 let mut simple = AmlScenario::new(
442 "SC-001",
443 AmlTypology::Structuring,
444 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
445 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
446 );
447 simple.add_customer(Uuid::new_v4());
448 simple.add_account(Uuid::new_v4());
449
450 let mut complex = AmlScenario::new(
451 "SC-002",
452 AmlTypology::Layering,
453 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
454 NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
455 )
456 .with_sophistication(Sophistication::Advanced);
457
458 for _ in 0..10 {
459 complex.add_customer(Uuid::new_v4());
460 complex.add_account(Uuid::new_v4());
461 }
462 complex.add_stage(LaunderingStage::Placement);
463 complex.add_stage(LaunderingStage::Layering);
464 complex.add_evasion_tactic(EvasionTactic::TimeJitter);
465 complex.add_evasion_tactic(EvasionTactic::AccountSplitting);
466
467 assert!(complex.complexity_score() > simple.complexity_score());
468 }
469}