use chrono::{Duration, NaiveDate};
use datasynth_core::models::banking::{
AmlTypology, Direction, LaunderingStage, Sophistication, TransactionCategory,
TransactionChannel,
};
use datasynth_core::DeterministicUuidFactory;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use crate::models::{
BankAccount, BankTransaction, BankingCustomer, CounterpartyRef, SanctionsScreening,
ScreeningResult,
};
use crate::seed_offsets::SANCTIONS_EVASION_SEED_OFFSET;
const TRANSSHIPMENT_COUNTRIES: &[&str] = &["TR", "AE", "SG", "MY", "GE", "AM", "KG", "KZ"];
const SANCTIONED_DESTINATIONS: &[&str] = &["IR", "KP", "SY", "CU", "VE", "MM", "BY", "RU"];
pub struct SanctionsEvasionInjector {
rng: ChaCha8Rng,
uuid_factory: DeterministicUuidFactory,
}
impl SanctionsEvasionInjector {
pub fn new(seed: u64) -> Self {
Self {
rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(SANCTIONS_EVASION_SEED_OFFSET)),
uuid_factory: DeterministicUuidFactory::new(
seed,
datasynth_core::GeneratorType::Anomaly,
),
}
}
fn generate_name_variations(&mut self, base_name: &str) -> Vec<String> {
let mut variations = vec![base_name.to_string()];
let parts: Vec<&str> = base_name.split_whitespace().collect();
if parts.len() >= 2 {
variations.push(format!(
"{} {}",
parts.last().unwrap_or(&""),
parts.first().unwrap_or(&"")
));
if let Some(first) = parts.first() {
if let Some(c) = first.chars().next() {
variations.push(format!("{}. {}", c, parts.last().unwrap_or(&"")));
}
}
}
let translits: &[(&str, &str)] = &[
("Mohammad", "Muhammad"),
("Muhammad", "Mohamed"),
("Ahmed", "Ahmad"),
("Ali", "Aly"),
("Hussein", "Husein"),
("Hassan", "Hasan"),
];
for (from, to) in translits {
if base_name.contains(from) {
variations.push(base_name.replace(from, to));
}
if base_name.contains(to) {
variations.push(base_name.replace(to, from));
}
}
variations.truncate(5);
variations
}
pub fn generate(
&mut self,
customer: &BankingCustomer,
account: &BankAccount,
start_date: NaiveDate,
end_date: NaiveDate,
sophistication: Sophistication,
) -> Vec<BankTransaction> {
let mut transactions = Vec::new();
let scenario_id = format!("SAN-{:06}", self.rng.random::<u32>());
let num_transfers = match sophistication {
Sophistication::Basic => self.rng.random_range(1..3),
Sophistication::Standard => self.rng.random_range(2..5),
Sophistication::Professional => self.rng.random_range(3..7),
Sophistication::Advanced => self.rng.random_range(5..10),
Sophistication::StateLevel => self.rng.random_range(8..15),
};
let hops = match sophistication {
Sophistication::Basic => 0, Sophistication::Standard => 1,
Sophistication::Professional => self.rng.random_range(1..3),
Sophistication::Advanced => self.rng.random_range(2..4),
Sophistication::StateLevel => self.rng.random_range(3..5),
};
let available_days = (end_date - start_date).num_days().max(1);
let name_variations = self.generate_name_variations(&customer.name.legal_name);
let sanctioned =
SANCTIONED_DESTINATIONS[self.rng.random_range(0..SANCTIONED_DESTINATIONS.len())];
let mut seq = 0u32;
for i in 0..num_transfers {
let day_offset = self.rng.random_range(0..(available_days as u32).max(1));
let date = start_date + Duration::days(day_offset as i64);
if date > end_date {
continue;
}
let amount: f64 = self.rng.random_range(5_000.0..50_000.0);
if hops == 0 {
let alias = &name_variations[self.rng.random_range(0..name_variations.len())];
let ts = date
.and_hms_opt(
self.rng.random_range(9..17),
self.rng.random_range(0..60),
0,
)
.map(|dt| dt.and_utc())
.unwrap_or_else(|| date.and_hms_opt(12, 0, 0).expect("valid").and_utc());
let mut txn = BankTransaction::new(
self.uuid_factory.next(),
account.account_id,
Decimal::from_f64_retain(amount).unwrap_or(Decimal::ONE_HUNDRED),
&account.currency,
Direction::Outbound,
TransactionChannel::Swift,
TransactionCategory::InternationalTransfer,
CounterpartyRef::sanctioned_entity(self.uuid_factory.next(), alias, sanctioned),
&format!("Wire transfer - {alias}"),
ts,
);
txn = txn.mark_suspicious(AmlTypology::SanctionsEvasion, &scenario_id);
txn = txn.with_laundering_stage(LaunderingStage::Placement);
txn = txn.with_scenario(&scenario_id, seq);
txn.ground_truth_explanation = Some(format!(
"Sanctions evasion: direct transfer ${:.0} to {sanctioned} using alias '{alias}' (transfer {}/{})",
amount, i + 1, num_transfers,
));
seq += 1;
transactions.push(txn);
} else {
let mut hop_amount = amount;
for hop in 0..=hops {
let hop_date =
date + Duration::days(hop as i64 * self.rng.random_range(1..4) as i64);
if hop_date > end_date {
break;
}
let ts = hop_date
.and_hms_opt(
self.rng.random_range(9..17),
self.rng.random_range(0..60),
0,
)
.map(|dt| dt.and_utc())
.unwrap_or_else(|| {
hop_date.and_hms_opt(12, 0, 0).expect("valid").and_utc()
});
let (dest_country, entity_name) = if hop < hops {
let tc = TRANSSHIPMENT_COUNTRIES
[self.rng.random_range(0..TRANSSHIPMENT_COUNTRIES.len())];
(tc, format!("{tc} Trading LLC"))
} else {
(sanctioned, format!("{sanctioned} Final Entity"))
};
let stage = if hop == 0 {
LaunderingStage::Placement
} else if hop < hops {
LaunderingStage::Layering
} else {
LaunderingStage::Integration
};
hop_amount *= 0.98;
let mut txn = BankTransaction::new(
self.uuid_factory.next(),
account.account_id,
Decimal::from_f64_retain(hop_amount).unwrap_or(Decimal::ONE_HUNDRED),
&account.currency,
Direction::Outbound,
TransactionChannel::Swift,
TransactionCategory::InternationalTransfer,
CounterpartyRef::sanctioned_entity(
self.uuid_factory.next(),
&entity_name,
dest_country,
),
&format!("Wire - {entity_name}"),
ts,
);
txn = txn.mark_suspicious(AmlTypology::SanctionsEvasion, &scenario_id);
txn = txn.with_laundering_stage(stage);
txn = txn.with_scenario(&scenario_id, seq);
txn.ground_truth_explanation = Some(format!(
"Sanctions evasion hop {}/{}: ${:.0} via {dest_country} → ultimate dest {sanctioned}",
hop + 1, hops + 1, hop_amount,
));
seq += 1;
transactions.push(txn);
}
}
}
transactions
}
pub fn generate_evasion_screening(
&mut self,
customer: &BankingCustomer,
screening_date: NaiveDate,
) -> SanctionsScreening {
let name_variations = self.generate_name_variations(&customer.name.legal_name);
SanctionsScreening {
last_screened: screening_date,
screening_result: ScreeningResult::Clear, matched_list: None,
match_score: 0.0,
name_variations,
is_true_match: true, }
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_sanctions_evasion_basic() {
let mut injector = SanctionsEvasionInjector::new(42);
let customer = BankingCustomer::new_business(
Uuid::new_v4(),
"Ahmad Trading",
"AE",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
);
let account = BankAccount::new(
Uuid::new_v4(),
"ACC-001".into(),
datasynth_core::models::banking::BankAccountType::BusinessOperating,
customer.customer_id,
"USD",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
);
let txns = injector.generate(
&customer,
&account,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
Sophistication::Professional,
);
assert!(!txns.is_empty());
assert!(txns
.iter()
.all(|t| t.suspicion_reason == Some(AmlTypology::SanctionsEvasion)));
assert!(txns.iter().all(|t| t.ground_truth_explanation.is_some()));
}
#[test]
fn test_name_variations() {
let mut injector = SanctionsEvasionInjector::new(42);
let vars = injector.generate_name_variations("Muhammad Ali Khan");
assert!(vars.len() >= 2);
assert!(vars.contains(&"Muhammad Ali Khan".to_string()));
}
#[test]
fn test_evasion_screening() {
let mut injector = SanctionsEvasionInjector::new(42);
let customer = BankingCustomer::new_retail(
Uuid::new_v4(),
"Test",
"User",
"US",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
);
let screening = injector
.generate_evasion_screening(&customer, NaiveDate::from_ymd_opt(2024, 6, 1).unwrap());
assert_eq!(screening.screening_result, ScreeningResult::Clear);
assert!(screening.is_true_match); }
}