1#![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
17pub struct RoundTrippingInjector {
25 rng: ChaCha8Rng,
26 uuid_factory: DeterministicUuidFactory,
27}
28
29impl RoundTrippingInjector {
30 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 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 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 let outbound_date = current_date;
72 let outbound_timestamp = self.random_timestamp(outbound_date);
73
74 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 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 let inbound_date = current_date;
107 let inbound_timestamp = self.random_timestamp(inbound_date);
108
109 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 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 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 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 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 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 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 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 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 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 assert!(transactions.len() >= 2);
347
348 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}