1use chrono::{DateTime, NaiveDate, Utc};
4use datasynth_core::models::banking::{
5 AmlTypology, Direction, LaunderingStage, Sophistication, TransactionCategory,
6 TransactionChannel,
7};
8use datasynth_core::DeterministicUuidFactory;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12
13use crate::models::{BankAccount, BankTransaction, BankingCustomer, CounterpartyRef};
14
15pub struct MuleInjector {
24 rng: ChaCha8Rng,
25 uuid_factory: DeterministicUuidFactory,
26}
27
28impl MuleInjector {
29 pub fn new(seed: u64) -> Self {
31 Self {
32 rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(6300)),
33 uuid_factory: DeterministicUuidFactory::new(
34 seed,
35 datasynth_core::GeneratorType::Anomaly,
36 ),
37 }
38 }
39
40 pub fn generate(
42 &mut self,
43 _customer: &BankingCustomer,
44 account: &BankAccount,
45 start_date: NaiveDate,
46 end_date: NaiveDate,
47 sophistication: Sophistication,
48 ) -> Vec<BankTransaction> {
49 let mut transactions = Vec::new();
50
51 let (num_cycles, amount_per_cycle, retention_pct) = match sophistication {
53 Sophistication::Basic => (1..3, 2_000.0..8_000.0, 0.05..0.10),
54 Sophistication::Standard => (2..5, 5_000.0..15_000.0, 0.08..0.15),
55 Sophistication::Professional => (3..7, 10_000.0..30_000.0, 0.10..0.20),
56 Sophistication::Advanced => (4..10, 20_000.0..75_000.0, 0.12..0.25),
57 Sophistication::StateLevel => (6..15, 50_000.0..200_000.0, 0.15..0.30),
58 };
59
60 let cycles = self.rng.gen_range(num_cycles);
61 let scenario_id = format!("MUL-{:06}", self.rng.gen::<u32>());
62
63 let available_days = (end_date - start_date).num_days().max(1);
64 let days_per_cycle = (available_days / cycles as i64).max(2);
65
66 let mut seq = 0u32;
67
68 for cycle in 0..cycles {
69 let cycle_start = start_date + chrono::Duration::days(cycle as i64 * days_per_cycle);
70 let amount: f64 = self.rng.gen_range(amount_per_cycle.clone());
71 let mule_cut: f64 = self.rng.gen_range(retention_pct.clone());
72
73 let num_inbound = match sophistication {
75 Sophistication::Basic => 1,
76 Sophistication::Standard => self.rng.gen_range(1..3),
77 _ => self.rng.gen_range(2..4),
78 };
79
80 let mut total_received = 0.0;
81 for i in 0..num_inbound {
82 let portion = if i == num_inbound - 1 {
83 amount - total_received
84 } else {
85 amount / num_inbound as f64
86 };
87 total_received += portion;
88
89 let in_day = self.rng.gen_range(0..2) as i64;
90 let in_date = cycle_start + chrono::Duration::days(in_day);
91 let in_timestamp = self.random_timestamp(in_date);
92
93 let (channel, counterparty) = self.random_mule_source();
94
95 let in_txn = BankTransaction::new(
96 self.uuid_factory.next(),
97 account.account_id,
98 Decimal::from_f64_retain(portion).unwrap_or(Decimal::ZERO),
99 &account.currency,
100 Direction::Inbound,
101 channel,
102 TransactionCategory::TransferIn,
103 counterparty,
104 &format!("Transfer cycle {} - {}", cycle + 1, i + 1),
105 in_timestamp,
106 )
107 .mark_suspicious(AmlTypology::MoneyMule, &scenario_id)
108 .with_laundering_stage(LaunderingStage::Placement)
109 .with_scenario(&scenario_id, seq);
110
111 transactions.push(in_txn);
112 seq += 1;
113 }
114
115 let cashout_delay = self.rng.gen_range(1..4) as i64;
117 let cashout_date = cycle_start + chrono::Duration::days(cashout_delay);
118
119 let amount_to_forward = total_received * (1.0 - mule_cut);
120
121 let cashout_methods = match sophistication {
123 Sophistication::Basic => vec![CashoutMethod::AtmWithdrawal],
124 Sophistication::Standard => {
125 vec![CashoutMethod::AtmWithdrawal, CashoutMethod::WireTransfer]
126 }
127 Sophistication::Professional => vec![
128 CashoutMethod::WireTransfer,
129 CashoutMethod::CryptoExchange,
130 CashoutMethod::GiftCards,
131 ],
132 Sophistication::Advanced => vec![
133 CashoutMethod::WireTransfer,
134 CashoutMethod::CryptoExchange,
135 CashoutMethod::MoneyOrder,
136 ],
137 Sophistication::StateLevel => vec![
138 CashoutMethod::WireTransfer,
139 CashoutMethod::InternationalWire,
140 CashoutMethod::CryptoExchange,
141 ],
142 };
143
144 let num_cashouts = match sophistication {
145 Sophistication::Basic => 1..2,
146 Sophistication::Standard => 1..3,
147 _ => 2..4,
148 };
149
150 let cashout_count = self.rng.gen_range(num_cashouts);
151 let mut remaining = amount_to_forward;
152
153 for i in 0..cashout_count {
154 let cashout_amount = if i == cashout_count - 1 {
155 remaining
156 } else {
157 remaining / ((cashout_count - i) as f64) * self.rng.gen_range(0.8..1.2)
158 };
159 remaining -= cashout_amount;
160
161 let method = cashout_methods[self.rng.gen_range(0..cashout_methods.len())];
162 let (channel, category, counterparty, description) = self.cashout_details(method);
163
164 let out_timestamp = self.random_timestamp(cashout_date);
165
166 let out_txn = BankTransaction::new(
167 self.uuid_factory.next(),
168 account.account_id,
169 Decimal::from_f64_retain(cashout_amount).unwrap_or(Decimal::ZERO),
170 &account.currency,
171 Direction::Outbound,
172 channel,
173 category,
174 counterparty,
175 &description,
176 out_timestamp,
177 )
178 .mark_suspicious(AmlTypology::MoneyMule, &scenario_id)
179 .with_laundering_stage(LaunderingStage::Integration)
180 .with_scenario(&scenario_id, seq);
181
182 transactions.push(out_txn);
183 seq += 1;
184 }
185 }
186
187 if matches!(
189 sophistication,
190 Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
191 ) {
192 for txn in &mut transactions {
193 txn.is_spoofed = true;
194 txn.spoofing_intensity = Some(sophistication.spoofing_intensity());
195 }
196 }
197
198 transactions
199 }
200
201 fn random_mule_source(&mut self) -> (TransactionChannel, CounterpartyRef) {
203 let source_type = self.rng.gen_range(0..4);
204 match source_type {
205 0 => (
206 TransactionChannel::Ach,
207 CounterpartyRef::person(&format!("Unknown Sender {}", self.rng.gen::<u16>())),
208 ),
209 1 => (
210 TransactionChannel::Ach,
211 CounterpartyRef::business(&format!("Dubious LLC {}", self.rng.gen::<u16>())),
212 ),
213 2 => (
214 TransactionChannel::Swift,
215 CounterpartyRef::international(&format!(
216 "Foreign Account {}",
217 self.rng.gen::<u16>()
218 )),
219 ),
220 _ => (
221 TransactionChannel::Wire,
222 CounterpartyRef::person(&format!("Contact {}", self.rng.gen::<u16>())),
223 ),
224 }
225 }
226
227 fn cashout_details(
229 &mut self,
230 method: CashoutMethod,
231 ) -> (
232 TransactionChannel,
233 TransactionCategory,
234 CounterpartyRef,
235 String,
236 ) {
237 match method {
238 CashoutMethod::AtmWithdrawal => (
239 TransactionChannel::Atm,
240 TransactionCategory::AtmWithdrawal,
241 CounterpartyRef::atm("ATM"),
242 "Cash withdrawal".to_string(),
243 ),
244 CashoutMethod::WireTransfer => (
245 TransactionChannel::Wire,
246 TransactionCategory::TransferOut,
247 CounterpartyRef::person(&format!("Recipient {}", self.rng.gen::<u16>())),
248 "Wire transfer".to_string(),
249 ),
250 CashoutMethod::InternationalWire => (
251 TransactionChannel::Swift,
252 TransactionCategory::InternationalTransfer,
253 CounterpartyRef::international(&format!(
254 "Overseas Account {}",
255 self.rng.gen::<u16>()
256 )),
257 "International wire".to_string(),
258 ),
259 CashoutMethod::CryptoExchange => (
260 TransactionChannel::Wire,
261 TransactionCategory::Investment,
262 CounterpartyRef::crypto_exchange("CryptoExchange"),
263 "Crypto purchase".to_string(),
264 ),
265 CashoutMethod::GiftCards => (
266 TransactionChannel::CardPresent,
267 TransactionCategory::Shopping,
268 CounterpartyRef::merchant_by_name("Gift Card Retailer", "5999"),
269 "Gift card purchase".to_string(),
270 ),
271 CashoutMethod::MoneyOrder => (
272 TransactionChannel::Branch,
273 TransactionCategory::Other,
274 CounterpartyRef::service("Money Order Service"),
275 "Money order".to_string(),
276 ),
277 }
278 }
279
280 fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
282 let hour: u32 = self.rng.gen_range(7..21);
283 let minute: u32 = self.rng.gen_range(0..60);
284 let second: u32 = self.rng.gen_range(0..60);
285
286 date.and_hms_opt(hour, minute, second)
287 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
288 .unwrap_or_else(Utc::now)
289 }
290}
291
292#[derive(Debug, Clone, Copy)]
294enum CashoutMethod {
295 AtmWithdrawal,
296 WireTransfer,
297 InternationalWire,
298 CryptoExchange,
299 GiftCards,
300 MoneyOrder,
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use uuid::Uuid;
307
308 #[test]
309 fn test_mule_generation() {
310 let mut injector = MuleInjector::new(12345);
311
312 let customer = BankingCustomer::new_retail(
313 Uuid::new_v4(),
314 "Test",
315 "User",
316 "US",
317 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
318 );
319
320 let account = BankAccount::new(
321 Uuid::new_v4(),
322 "****1234".to_string(),
323 datasynth_core::models::banking::BankAccountType::Checking,
324 customer.customer_id,
325 "USD",
326 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
327 );
328
329 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
330 let end = NaiveDate::from_ymd_opt(2024, 2, 28).unwrap();
331
332 let transactions =
333 injector.generate(&customer, &account, start, end, Sophistication::Standard);
334
335 assert!(!transactions.is_empty());
336
337 let inbound: Vec<_> = transactions
339 .iter()
340 .filter(|t| t.direction == Direction::Inbound)
341 .collect();
342 let outbound: Vec<_> = transactions
343 .iter()
344 .filter(|t| t.direction == Direction::Outbound)
345 .collect();
346
347 assert!(!inbound.is_empty());
348 assert!(!outbound.is_empty());
349
350 for txn in &transactions {
352 assert!(txn.is_suspicious);
353 assert_eq!(txn.suspicion_reason, Some(AmlTypology::MoneyMule));
354 }
355 }
356}