Skip to main content

datasynth_banking/typologies/
mule.rs

1//! Money mule typology implementation.
2
3use 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
15/// Money mule pattern injector.
16///
17/// Money mule accounts show:
18/// - New account with limited history
19/// - Inbound transfers from unknown/unrelated sources
20/// - Rapid cash-out via ATM withdrawals or wire transfers
21/// - Pattern of receive-and-forward behavior
22/// - Little legitimate activity
23pub struct MuleInjector {
24    rng: ChaCha8Rng,
25    uuid_factory: DeterministicUuidFactory,
26}
27
28impl MuleInjector {
29    /// Create a new mule injector.
30    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    /// Generate money mule transactions.
41    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        // Mule parameters based on sophistication
52        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            // Phase 1: Inbound transfer(s)
74            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            // Phase 2: Rapid cash-out (within 1-3 days)
116            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            // Cash-out method varies by sophistication
122            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        // Apply spoofing for sophisticated patterns
188        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    /// Generate random mule source.
202    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    /// Get cash-out transaction details.
228    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    /// Generate random timestamp for a date.
281    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/// Cash-out method for mule accounts.
293#[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        // Should have both inbound and outbound
338        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        // All should be marked as money mule
351        for txn in &transactions {
352            assert!(txn.is_suspicious);
353            assert_eq!(txn.suspicion_reason, Some(AmlTypology::MoneyMule));
354        }
355    }
356}