use chrono::NaiveDate;
use datasynth_core::models::banking::{AmlTypology, LaunderingStage, Sophistication};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmlScenario {
pub scenario_id: String,
pub typology: AmlTypology,
pub secondary_typologies: Vec<AmlTypology>,
pub stages: Vec<LaunderingStage>,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub involved_customers: Vec<Uuid>,
pub involved_accounts: Vec<Uuid>,
pub involved_transactions: Vec<Uuid>,
#[serde(with = "rust_decimal::serde::str")]
pub total_amount: Decimal,
pub evasion_tactics: Vec<EvasionTactic>,
pub sophistication: Sophistication,
pub detectability: f64,
pub narrative: CaseNarrative,
pub expected_alerts: Vec<ExpectedAlert>,
pub was_successful: bool,
}
impl AmlScenario {
pub fn new(
scenario_id: &str,
typology: AmlTypology,
start_date: NaiveDate,
end_date: NaiveDate,
) -> Self {
Self {
scenario_id: scenario_id.to_string(),
typology,
secondary_typologies: Vec::new(),
stages: Vec::new(),
start_date,
end_date,
involved_customers: Vec::new(),
involved_accounts: Vec::new(),
involved_transactions: Vec::new(),
total_amount: Decimal::ZERO,
evasion_tactics: Vec::new(),
sophistication: Sophistication::default(),
detectability: typology.severity() as f64 / 10.0,
narrative: CaseNarrative::default(),
expected_alerts: Vec::new(),
was_successful: true,
}
}
pub fn add_stage(&mut self, stage: LaunderingStage) {
if !self.stages.contains(&stage) {
self.stages.push(stage);
}
}
pub fn add_customer(&mut self, customer_id: Uuid) {
if !self.involved_customers.contains(&customer_id) {
self.involved_customers.push(customer_id);
}
}
pub fn add_account(&mut self, account_id: Uuid) {
if !self.involved_accounts.contains(&account_id) {
self.involved_accounts.push(account_id);
}
}
pub fn add_transaction(&mut self, transaction_id: Uuid, amount: Decimal) {
self.involved_transactions.push(transaction_id);
self.total_amount += amount;
}
pub fn add_evasion_tactic(&mut self, tactic: EvasionTactic) {
if !self.evasion_tactics.contains(&tactic) {
self.evasion_tactics.push(tactic);
self.detectability *= 1.0 / tactic.difficulty_modifier();
}
}
pub fn with_sophistication(mut self, sophistication: Sophistication) -> Self {
self.sophistication = sophistication;
self.detectability *= sophistication.detectability_modifier();
self
}
pub fn complexity_score(&self) -> u8 {
let mut score = 0.0;
score += (self.involved_customers.len().max(1) as f64).ln() * 10.0;
score += (self.involved_accounts.len().max(1) as f64).ln() * 5.0;
score += (self.involved_transactions.len().max(1) as f64).ln() * 3.0;
let duration = (self.end_date - self.start_date).num_days();
score += (duration as f64 / 30.0).min(10.0) * 3.0;
score += self.evasion_tactics.len() as f64 * 5.0;
score += match self.sophistication {
Sophistication::Basic => 0.0,
Sophistication::Standard => 10.0,
Sophistication::Professional => 20.0,
Sophistication::Advanced => 30.0,
Sophistication::StateLevel => 40.0,
};
score += self.stages.len() as f64 * 5.0;
score.min(100.0) as u8
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CaseNarrative {
pub storyline: String,
pub evidence_points: Vec<String>,
pub violated_expectations: Vec<ViolatedExpectation>,
pub red_flags: Vec<RedFlag>,
pub recommendation: CaseRecommendation,
pub investigation_notes: Vec<String>,
}
impl CaseNarrative {
pub fn new(storyline: &str) -> Self {
Self {
storyline: storyline.to_string(),
..Default::default()
}
}
pub fn add_evidence(&mut self, evidence: &str) {
self.evidence_points.push(evidence.to_string());
}
pub fn add_violated_expectation(&mut self, expectation: ViolatedExpectation) {
self.violated_expectations.push(expectation);
}
pub fn add_red_flag(&mut self, flag: RedFlag) {
self.red_flags.push(flag);
}
pub fn with_recommendation(mut self, recommendation: CaseRecommendation) -> Self {
self.recommendation = recommendation;
self
}
pub fn generate_text(&self) -> String {
let mut text = format!("## Case Summary\n\n{}\n\n", self.storyline);
if !self.evidence_points.is_empty() {
text.push_str("## Evidence Points\n\n");
for (i, point) in self.evidence_points.iter().enumerate() {
text.push_str(&format!("{}. {}\n", i + 1, point));
}
text.push('\n');
}
if !self.violated_expectations.is_empty() {
text.push_str("## Violated Expectations\n\n");
for ve in &self.violated_expectations {
text.push_str(&format!(
"- **{}**: Expected {}, Actual {}\n",
ve.expectation_type, ve.expected_value, ve.actual_value
));
}
text.push('\n');
}
if !self.red_flags.is_empty() {
text.push_str("## Red Flags\n\n");
for flag in &self.red_flags {
text.push_str(&format!(
"- {} (Severity: {})\n",
flag.description, flag.severity
));
}
text.push('\n');
}
text.push_str(&format!(
"## Recommendation\n\n{}\n",
self.recommendation.description()
));
text
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViolatedExpectation {
pub expectation_type: String,
pub expected_value: String,
pub actual_value: String,
pub deviation_percentage: f64,
}
impl ViolatedExpectation {
pub fn new(expectation_type: &str, expected: &str, actual: &str, deviation: f64) -> Self {
Self {
expectation_type: expectation_type.to_string(),
expected_value: expected.to_string(),
actual_value: actual.to_string(),
deviation_percentage: deviation,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedFlag {
pub category: RedFlagCategory,
pub description: String,
pub severity: u8,
pub date_identified: NaiveDate,
}
impl RedFlag {
pub fn new(
category: RedFlagCategory,
description: &str,
severity: u8,
date: NaiveDate,
) -> Self {
Self {
category,
description: description.to_string(),
severity,
date_identified: date,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RedFlagCategory {
ActivityPattern,
Geographic,
CustomerBehavior,
TransactionCharacteristic,
AccountCharacteristic,
ThirdParty,
Timing,
Documentation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CaseRecommendation {
#[default]
CloseNoAction,
ContinueMonitoring,
EnhancedMonitoring,
EscalateCompliance,
FileSar,
CloseAccount,
ReportLawEnforcement,
}
impl CaseRecommendation {
pub fn description(&self) -> &'static str {
match self {
Self::CloseNoAction => "Close case - no suspicious activity identified",
Self::ContinueMonitoring => "Continue standard monitoring",
Self::EnhancedMonitoring => "Place customer under enhanced monitoring",
Self::EscalateCompliance => "Escalate to compliance officer for review",
Self::FileSar => "Escalate to SAR filing",
Self::CloseAccount => "Close account and file SAR",
Self::ReportLawEnforcement => "Report to law enforcement immediately",
}
}
pub fn severity(&self) -> u8 {
match self {
Self::CloseNoAction => 1,
Self::ContinueMonitoring => 1,
Self::EnhancedMonitoring => 2,
Self::EscalateCompliance => 3,
Self::FileSar => 4,
Self::CloseAccount => 4,
Self::ReportLawEnforcement => 5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpectedAlert {
pub alert_type: String,
pub expected_date: NaiveDate,
pub triggering_transactions: Vec<Uuid>,
pub severity: AlertSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AlertSeverity {
#[default]
Low,
Medium,
High,
Critical,
}
pub use datasynth_core::models::banking::EvasionTactic;
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_aml_scenario() {
let scenario = AmlScenario::new(
"SC-2024-001",
AmlTypology::Structuring,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
);
assert_eq!(scenario.typology, AmlTypology::Structuring);
assert!(scenario.was_successful);
}
#[test]
fn test_case_narrative() {
let mut narrative = CaseNarrative::new(
"Subject conducted 12 cash deposits just below $10,000 threshold over 3 days.",
);
narrative.add_evidence("12 deposits ranging from $9,500 to $9,900");
narrative.add_evidence("All deposits at different branch locations");
narrative.add_violated_expectation(ViolatedExpectation::new(
"Monthly deposits",
"2",
"12",
500.0,
));
let text = narrative.generate_text();
assert!(text.contains("12 cash deposits"));
assert!(text.contains("Evidence Points"));
}
#[test]
fn test_complexity_score() {
let mut simple = AmlScenario::new(
"SC-001",
AmlTypology::Structuring,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
);
simple.add_customer(Uuid::new_v4());
simple.add_account(Uuid::new_v4());
let mut complex = AmlScenario::new(
"SC-002",
AmlTypology::Layering,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
)
.with_sophistication(Sophistication::Advanced);
for _ in 0..10 {
complex.add_customer(Uuid::new_v4());
complex.add_account(Uuid::new_v4());
}
complex.add_stage(LaunderingStage::Placement);
complex.add_stage(LaunderingStage::Layering);
complex.add_evasion_tactic(EvasionTactic::TimeJitter);
complex.add_evasion_tactic(EvasionTactic::AccountSplitting);
assert!(complex.complexity_score() > simple.complexity_score());
}
}