Skip to main content

datasynth_banking/typologies/
round_tripping.rs

1//! Round-tripping typology implementation.
2
3#![allow(clippy::too_many_arguments)]
4
5use chrono::{DateTime, NaiveDate, Utc};
6use datasynth_core::models::banking::{
7    AmlTypology, Direction, LaunderingStage, Sophistication, TransactionCategory,
8    TransactionChannel,
9};
10use datasynth_core::DeterministicUuidFactory;
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::Decimal;
14
15use crate::models::{BankAccount, BankTransaction, BankingCustomer, CounterpartyRef};
16
17/// Round-tripping pattern injector.
18///
19/// Round-tripping involves:
20/// - Funds leaving the country and returning via affiliates/shells
21/// - Complex ownership structures to obscure beneficial ownership
22/// - Transfer pricing manipulation
23/// - Trade-based laundering variants
24pub struct RoundTrippingInjector {
25    rng: ChaCha8Rng,
26    uuid_factory: DeterministicUuidFactory,
27}
28
29impl RoundTrippingInjector {
30    /// Create a new round-tripping injector.
31    pub fn new(seed: u64) -> Self {
32        Self {
33            rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(7100)),
34            uuid_factory: DeterministicUuidFactory::new(
35                seed,
36                datasynth_core::GeneratorType::Anomaly,
37            ),
38        }
39    }
40
41    /// Generate round-tripping transactions.
42    pub fn generate(
43        &mut self,
44        _customer: &BankingCustomer,
45        account: &BankAccount,
46        start_date: NaiveDate,
47        end_date: NaiveDate,
48        sophistication: Sophistication,
49    ) -> Vec<BankTransaction> {
50        let mut transactions = Vec::new();
51
52        // Round-tripping parameters based on sophistication
53        let (num_trips, total_amount, trip_delay_days) = match sophistication {
54            Sophistication::Basic => (1..2, 25_000.0..75_000.0, 7..14),
55            Sophistication::Standard => (2..4, 50_000.0..200_000.0, 10..21),
56            Sophistication::Professional => (3..6, 100_000.0..500_000.0, 14..30),
57            Sophistication::Advanced => (4..8, 250_000.0..1_000_000.0, 21..45),
58            Sophistication::StateLevel => (6..12, 750_000.0..5_000_000.0, 30..60),
59        };
60
61        let trips = self.rng.gen_range(num_trips);
62        let base_amount: f64 = self.rng.gen_range(total_amount);
63        let scenario_id = format!("RND-{:06}", self.rng.gen::<u32>());
64
65        let _available_days = (end_date - start_date).num_days().max(1);
66        let mut current_date = start_date;
67        let mut seq = 0u32;
68
69        for trip in 0..trips {
70            // Outbound leg - money leaves to offshore entity
71            let outbound_date = current_date;
72            let outbound_timestamp = self.random_timestamp(outbound_date);
73
74            // Amount varies slightly each trip (with "fees")
75            let trip_amount = base_amount * (0.95 + self.rng.gen::<f64>() * 0.1);
76            let offshore_entity = self.random_offshore_entity(trip);
77
78            let outbound_txn = BankTransaction::new(
79                self.uuid_factory.next(),
80                account.account_id,
81                Decimal::from_f64_retain(trip_amount).unwrap_or(Decimal::ZERO),
82                &account.currency,
83                Direction::Outbound,
84                TransactionChannel::Swift,
85                TransactionCategory::TransferOut,
86                CounterpartyRef::business(&offshore_entity.0),
87                &format!("Investment in {} - {}", offshore_entity.1, trip + 1),
88                outbound_timestamp,
89            )
90            .mark_suspicious(AmlTypology::RoundTripping, &scenario_id)
91            .with_laundering_stage(LaunderingStage::Layering)
92            .with_scenario(&scenario_id, seq);
93
94            transactions.push(outbound_txn);
95            seq += 1;
96
97            // Delay before return
98            let delay = self.rng.gen_range(trip_delay_days.clone()) as i64;
99            current_date = outbound_date + chrono::Duration::days(delay);
100
101            if current_date > end_date {
102                current_date = end_date;
103            }
104
105            // Inbound leg - money returns from different offshore entity
106            let inbound_date = current_date;
107            let inbound_timestamp = self.random_timestamp(inbound_date);
108
109            // Return amount varies (profits, fees, etc.)
110            let return_multiplier = match sophistication {
111                Sophistication::Basic => 0.98..1.02,
112                Sophistication::Standard => 0.95..1.10,
113                Sophistication::Professional => 0.90..1.20,
114                Sophistication::Advanced => 0.85..1.30,
115                Sophistication::StateLevel => 0.80..1.50,
116            };
117            let return_amount = trip_amount * self.rng.gen_range(return_multiplier);
118
119            let return_entity = self.random_return_entity(trip);
120            let return_reference = self.random_return_reference();
121
122            let inbound_txn = BankTransaction::new(
123                self.uuid_factory.next(),
124                account.account_id,
125                Decimal::from_f64_retain(return_amount).unwrap_or(Decimal::ZERO),
126                &account.currency,
127                Direction::Inbound,
128                TransactionChannel::Swift,
129                TransactionCategory::TransferIn,
130                CounterpartyRef::business(&return_entity),
131                &return_reference,
132                inbound_timestamp,
133            )
134            .mark_suspicious(AmlTypology::RoundTripping, &scenario_id)
135            .with_laundering_stage(LaunderingStage::Integration)
136            .with_scenario(&scenario_id, seq);
137
138            transactions.push(inbound_txn);
139            seq += 1;
140
141            // For sophisticated cases, add intermediate transactions
142            if matches!(
143                sophistication,
144                Sophistication::Professional
145                    | Sophistication::Advanced
146                    | Sophistication::StateLevel
147            ) {
148                self.add_intermediate_transactions(
149                    &mut transactions,
150                    account,
151                    outbound_date,
152                    inbound_date,
153                    &scenario_id,
154                    &mut seq,
155                    sophistication,
156                );
157            }
158
159            // Move to next trip
160            let gap = self.rng.gen_range(3..10) as i64;
161            current_date += chrono::Duration::days(gap);
162
163            if current_date > end_date - chrono::Duration::days(trip_delay_days.start as i64) {
164                break;
165            }
166        }
167
168        // Apply spoofing for sophisticated patterns
169        if matches!(
170            sophistication,
171            Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
172        ) {
173            for txn in &mut transactions {
174                txn.is_spoofed = true;
175                txn.spoofing_intensity = Some(sophistication.spoofing_intensity());
176            }
177        }
178
179        transactions
180    }
181
182    /// Add intermediate transactions for more sophisticated schemes.
183    fn add_intermediate_transactions(
184        &mut self,
185        transactions: &mut Vec<BankTransaction>,
186        account: &BankAccount,
187        start_date: NaiveDate,
188        end_date: NaiveDate,
189        scenario_id: &str,
190        seq: &mut u32,
191        sophistication: Sophistication,
192    ) {
193        let num_intermediate = match sophistication {
194            Sophistication::Professional => self.rng.gen_range(1..3),
195            Sophistication::Advanced => self.rng.gen_range(2..5),
196            Sophistication::StateLevel => self.rng.gen_range(3..8),
197            _ => 0,
198        };
199
200        let available_days = (end_date - start_date).num_days().max(1);
201
202        for i in 0..num_intermediate {
203            let day_offset = self.rng.gen_range(1..available_days);
204            let txn_date = start_date + chrono::Duration::days(day_offset);
205            let timestamp = self.random_timestamp(txn_date);
206
207            // Small intermediate transfers to add complexity
208            let amount = self.rng.gen_range(1_000.0..25_000.0);
209            let direction = if self.rng.gen::<bool>() {
210                Direction::Outbound
211            } else {
212                Direction::Inbound
213            };
214
215            let intermediary = format!("Intermediary {} Ltd", i + 1);
216            let reference = format!("Advisory fee payment {}", i + 1);
217
218            let txn = BankTransaction::new(
219                self.uuid_factory.next(),
220                account.account_id,
221                Decimal::from_f64_retain(amount).unwrap_or(Decimal::ZERO),
222                &account.currency,
223                direction,
224                TransactionChannel::Wire,
225                TransactionCategory::Other,
226                CounterpartyRef::business(&intermediary),
227                &reference,
228                timestamp,
229            )
230            .mark_suspicious(AmlTypology::RoundTripping, scenario_id)
231            .with_laundering_stage(LaunderingStage::Layering)
232            .with_scenario(scenario_id, *seq);
233
234            transactions.push(txn);
235            *seq += 1;
236        }
237    }
238
239    /// Generate random offshore entity destination.
240    fn random_offshore_entity(&mut self, index: usize) -> (String, String) {
241        let entities = [
242            ("Cayman Holding Co Ltd", "Cayman Islands"),
243            ("BVI Investment Corp", "British Virgin Islands"),
244            ("Singapore Ventures Pte Ltd", "Singapore"),
245            ("Luxembourg Capital SA", "Luxembourg"),
246            ("Cyprus Trading Ltd", "Cyprus"),
247            ("Malta Holdings Ltd", "Malta"),
248            ("Jersey Finance Ltd", "Jersey"),
249            ("Guernsey Trust Ltd", "Guernsey"),
250            ("Panama Investments SA", "Panama"),
251            ("Delaware Holdings LLC", "Delaware"),
252        ];
253
254        let idx = (index + self.rng.gen_range(0..entities.len())) % entities.len();
255        (entities[idx].0.to_string(), entities[idx].1.to_string())
256    }
257
258    /// Generate random return entity name.
259    fn random_return_entity(&mut self, _index: usize) -> String {
260        let entities = [
261            "Global Trade Finance Ltd",
262            "International Consulting Services",
263            "Worldwide Investment Partners",
264            "Pacific Rim Holdings",
265            "Atlantic Capital Management",
266            "European Trading Company",
267            "Asian Growth Fund",
268            "Mediterranean Investments",
269            "Nordic Ventures AB",
270            "Swiss Financial Services AG",
271        ];
272
273        entities[self.rng.gen_range(0..entities.len())].to_string()
274    }
275
276    /// Generate random return reference.
277    fn random_return_reference(&mut self) -> String {
278        let references = [
279            "Dividend distribution",
280            "Investment return",
281            "Consulting fees",
282            "Management fee rebate",
283            "Performance bonus",
284            "Profit share",
285            "Loan repayment",
286            "Capital return",
287            "Advisory fee",
288            "Commission payment",
289        ];
290
291        references[self.rng.gen_range(0..references.len())].to_string()
292    }
293
294    /// Generate random timestamp for a date.
295    fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
296        let hour: u32 = self.rng.gen_range(8..18);
297        let minute: u32 = self.rng.gen_range(0..60);
298        let second: u32 = self.rng.gen_range(0..60);
299
300        date.and_hms_opt(hour, minute, second)
301            .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
302            .unwrap_or_else(Utc::now)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use uuid::Uuid;
310
311    #[test]
312    fn test_round_tripping_generation() {
313        let mut injector = RoundTrippingInjector::new(12345);
314
315        let customer = BankingCustomer::new_retail(
316            Uuid::new_v4(),
317            "Test",
318            "User",
319            "US",
320            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
321        );
322
323        let account = BankAccount::new(
324            Uuid::new_v4(),
325            "****1234".to_string(),
326            datasynth_core::models::banking::BankAccountType::Checking,
327            customer.customer_id,
328            "USD",
329            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
330        );
331
332        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
333        let end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
334
335        let transactions = injector.generate(
336            &customer,
337            &account,
338            start,
339            end,
340            Sophistication::Professional,
341        );
342
343        assert!(!transactions.is_empty());
344
345        // Should have pairs of outbound/inbound transactions
346        assert!(transactions.len() >= 2);
347
348        // All should be marked as round-tripping
349        for txn in &transactions {
350            assert!(txn.is_suspicious);
351            assert_eq!(txn.suspicion_reason, Some(AmlTypology::RoundTripping));
352        }
353    }
354
355    #[test]
356    fn test_round_tripping_has_both_directions() {
357        let mut injector = RoundTrippingInjector::new(54321);
358
359        let customer = BankingCustomer::new_business(
360            Uuid::new_v4(),
361            "Test Corp",
362            "US",
363            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
364        );
365
366        let account = BankAccount::new(
367            Uuid::new_v4(),
368            "****5678".to_string(),
369            datasynth_core::models::banking::BankAccountType::BusinessOperating,
370            customer.customer_id,
371            "USD",
372            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
373        );
374
375        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
376        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
377
378        let transactions =
379            injector.generate(&customer, &account, start, end, Sophistication::Standard);
380
381        let has_outbound = transactions
382            .iter()
383            .any(|t| t.direction == Direction::Outbound);
384        let has_inbound = transactions
385            .iter()
386            .any(|t| t.direction == Direction::Inbound);
387
388        assert!(has_outbound, "Should have outbound transactions");
389        assert!(has_inbound, "Should have inbound transactions (return leg)");
390    }
391}