datasynth_banking/typologies/
layering.rs1use 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 LayeringInjector {
23 rng: ChaCha8Rng,
24 uuid_factory: DeterministicUuidFactory,
25}
26
27impl LayeringInjector {
28 pub fn new(seed: u64) -> Self {
30 Self {
31 rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(6200)),
32 uuid_factory: DeterministicUuidFactory::new(
33 seed,
34 datasynth_core::GeneratorType::Anomaly,
35 ),
36 }
37 }
38
39 pub fn generate(
41 &mut self,
42 _customer: &BankingCustomer,
43 account: &BankAccount,
44 start_date: NaiveDate,
45 end_date: NaiveDate,
46 sophistication: Sophistication,
47 ) -> Vec<BankTransaction> {
48 let mut transactions = Vec::new();
49
50 let (num_layers, total_amount, jitter_range) = match sophistication {
52 Sophistication::Basic => (2..4, 15_000.0..50_000.0, 1..3),
53 Sophistication::Standard => (3..5, 30_000.0..100_000.0, 2..5),
54 Sophistication::Professional => (4..7, 75_000.0..300_000.0, 3..10),
55 Sophistication::Advanced => (5..10, 150_000.0..750_000.0, 5..20),
56 Sophistication::StateLevel => (8..15, 500_000.0..3_000_000.0, 10..45),
57 };
58
59 let layers = self.rng.gen_range(num_layers);
60 let total: f64 = self.rng.gen_range(total_amount);
61 let scenario_id = format!("LAY-{:06}", self.rng.gen::<u32>());
62
63 let available_days = (end_date - start_date).num_days().max(1);
64
65 let placement_date = start_date;
67 let placement_timestamp = self.random_timestamp(placement_date);
68
69 let placement_txn = BankTransaction::new(
70 self.uuid_factory.next(),
71 account.account_id,
72 Decimal::from_f64_retain(total).unwrap_or(Decimal::ZERO),
73 &account.currency,
74 Direction::Inbound,
75 TransactionChannel::Wire,
76 TransactionCategory::TransferIn,
77 CounterpartyRef::business("Initial Source LLC"),
78 "Initial transfer",
79 placement_timestamp,
80 )
81 .mark_suspicious(AmlTypology::Layering, &scenario_id)
82 .with_laundering_stage(LaunderingStage::Placement)
83 .with_scenario(&scenario_id, 0);
84
85 transactions.push(placement_txn);
86
87 let mut current_amount = total;
89 let mut current_date = placement_date;
90 let mut seq = 1u32;
91
92 for layer in 0..layers {
93 let jitter = self.rng.gen_range(jitter_range.clone()) as i64;
95 current_date += chrono::Duration::days(jitter);
96
97 if current_date > end_date {
98 current_date = end_date;
99 }
100
101 let num_slices = if matches!(
103 sophistication,
104 Sophistication::Professional
105 | Sophistication::Advanced
106 | Sophistication::StateLevel
107 ) {
108 self.rng.gen_range(2..4)
109 } else {
110 1
111 };
112
113 let mut remaining = current_amount;
114
115 for slice in 0..num_slices {
116 let slice_amount = if slice == num_slices - 1 {
117 remaining * 0.98 } else {
119 let portion = remaining / ((num_slices - slice) as f64);
120 let variance = portion * 0.2;
121 self.rng
122 .gen_range((portion - variance)..(portion + variance))
123 };
124 remaining -= slice_amount;
125
126 let out_timestamp = self.random_timestamp(current_date);
128 let (out_channel, counterparty_name) = self.random_layer_destination(layer);
129
130 let out_txn = BankTransaction::new(
131 self.uuid_factory.next(),
132 account.account_id,
133 Decimal::from_f64_retain(slice_amount).unwrap_or(Decimal::ZERO),
134 &account.currency,
135 Direction::Outbound,
136 out_channel,
137 TransactionCategory::TransferOut,
138 CounterpartyRef::business(&counterparty_name),
139 &format!("Layer {} transfer {}", layer + 1, slice + 1),
140 out_timestamp,
141 )
142 .mark_suspicious(AmlTypology::Layering, &scenario_id)
143 .with_laundering_stage(LaunderingStage::Layering)
144 .with_scenario(&scenario_id, seq);
145
146 transactions.push(out_txn);
147 seq += 1;
148
149 if layer < layers - 1 && self.rng.gen::<f64>() < 0.6 {
151 let return_jitter = self.rng.gen_range(1..3) as i64;
152 let return_date = current_date + chrono::Duration::days(return_jitter);
153 let return_timestamp = self.random_timestamp(return_date);
154
155 let return_amount = slice_amount * 0.97; let in_txn = BankTransaction::new(
158 self.uuid_factory.next(),
159 account.account_id,
160 Decimal::from_f64_retain(return_amount).unwrap_or(Decimal::ZERO),
161 &account.currency,
162 Direction::Inbound,
163 TransactionChannel::Wire,
164 TransactionCategory::TransferIn,
165 CounterpartyRef::business(&format!("Intermediary {} Holdings", layer + 1)),
166 &format!("Return transfer layer {}", layer + 1),
167 return_timestamp,
168 )
169 .mark_suspicious(AmlTypology::Layering, &scenario_id)
170 .with_laundering_stage(LaunderingStage::Layering)
171 .with_scenario(&scenario_id, seq);
172
173 transactions.push(in_txn);
174 seq += 1;
175 current_amount = return_amount;
176 }
177 }
178 }
179
180 if matches!(
182 sophistication,
183 Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
184 ) {
185 let cover_count = match sophistication {
186 Sophistication::Professional => 2..5,
187 Sophistication::Advanced => 4..8,
188 Sophistication::StateLevel => 6..12,
189 _ => 1..2,
190 };
191
192 for _ in 0..self.rng.gen_range(cover_count) {
193 let cover_day = self.rng.gen_range(0..available_days);
194 let cover_date = start_date + chrono::Duration::days(cover_day);
195 let cover_timestamp = self.random_timestamp(cover_date);
196
197 let cover_amount = self.rng.gen_range(100.0..5000.0);
199 let direction = if self.rng.gen::<bool>() {
200 Direction::Inbound
201 } else {
202 Direction::Outbound
203 };
204
205 let cover_txn = BankTransaction::new(
206 self.uuid_factory.next(),
207 account.account_id,
208 Decimal::from_f64_retain(cover_amount).unwrap_or(Decimal::ZERO),
209 &account.currency,
210 direction,
211 TransactionChannel::CardPresent,
212 TransactionCategory::Shopping,
213 CounterpartyRef::merchant_by_name("Regular Merchant", "5411"),
214 "Regular purchase",
215 cover_timestamp,
216 )
217 .mark_suspicious(AmlTypology::Layering, &scenario_id)
218 .with_laundering_stage(LaunderingStage::Layering)
219 .with_scenario(&scenario_id, seq);
220
221 transactions.push(cover_txn);
222 seq += 1;
223 }
224 }
225
226 if matches!(
228 sophistication,
229 Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
230 ) {
231 for txn in &mut transactions {
232 txn.is_spoofed = true;
233 txn.spoofing_intensity = Some(sophistication.spoofing_intensity());
234 }
235 }
236
237 transactions
238 }
239
240 fn random_layer_destination(&mut self, layer: usize) -> (TransactionChannel, String) {
242 let destinations = [
243 (
244 TransactionChannel::Wire,
245 format!("Offshore Holdings {}", layer + 1),
246 ),
247 (
248 TransactionChannel::Ach,
249 format!("Investment Co {}", layer + 1),
250 ),
251 (
252 TransactionChannel::Swift,
253 format!("Trade Finance {} Ltd", layer + 1),
254 ),
255 (
256 TransactionChannel::Wire,
257 format!("Consulting {} LLC", layer + 1),
258 ),
259 ];
260
261 let idx = self.rng.gen_range(0..destinations.len());
262 destinations[idx].clone()
263 }
264
265 fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
267 let hour: u32 = self.rng.gen_range(6..22);
268 let minute: u32 = self.rng.gen_range(0..60);
269 let second: u32 = self.rng.gen_range(0..60);
270
271 date.and_hms_opt(hour, minute, second)
272 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
273 .unwrap_or_else(Utc::now)
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use uuid::Uuid;
281
282 #[test]
283 fn test_layering_generation() {
284 let mut injector = LayeringInjector::new(12345);
285
286 let customer = BankingCustomer::new_retail(
287 Uuid::new_v4(),
288 "Test",
289 "User",
290 "US",
291 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
292 );
293
294 let account = BankAccount::new(
295 Uuid::new_v4(),
296 "****1234".to_string(),
297 datasynth_core::models::banking::BankAccountType::Checking,
298 customer.customer_id,
299 "USD",
300 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
301 );
302
303 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
304 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
305
306 let transactions = injector.generate(
307 &customer,
308 &account,
309 start,
310 end,
311 Sophistication::Professional,
312 );
313
314 assert!(!transactions.is_empty());
315
316 assert!(transactions.len() >= 3);
318
319 for txn in &transactions {
321 assert!(txn.is_suspicious);
322 assert_eq!(txn.suspicion_reason, Some(AmlTypology::Layering));
323 }
324 }
325}