datasynth_banking/typologies/
funnel.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 FunnelInjector {
23 rng: ChaCha8Rng,
24 uuid_factory: DeterministicUuidFactory,
25}
26
27impl FunnelInjector {
28 pub fn new(seed: u64) -> Self {
30 Self {
31 rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(6100)),
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_sources, total_amount, holding_days) = match sophistication {
52 Sophistication::Basic => (5..10, 20_000.0..50_000.0, 1..3),
53 Sophistication::Standard => (8..15, 50_000.0..150_000.0, 2..5),
54 Sophistication::Professional => (12..25, 100_000.0..500_000.0, 3..7),
55 Sophistication::Advanced => (20..40, 250_000.0..1_000_000.0, 5..14),
56 Sophistication::StateLevel => (30..60, 500_000.0..5_000_000.0, 7..30),
57 };
58
59 let num_inbound = self.rng.gen_range(num_sources);
60 let total: f64 = self.rng.gen_range(total_amount);
61 let hold_period = self.rng.gen_range(holding_days) as i64;
62
63 let available_days = (end_date - start_date).num_days().max(1) as u32;
64 let scenario_id = format!("FUN-{:06}", self.rng.gen::<u32>());
65
66 let mut accumulated = 0.0;
68 let inbound_window = (available_days as i64 / 3).max(1);
69
70 for i in 0..num_inbound {
71 let portion = if i == num_inbound - 1 {
72 total - accumulated
73 } else {
74 let min_portion = total / (num_inbound as f64 * 2.0);
75 let max_portion = total / (num_inbound as f64) * 1.5;
76 self.rng.gen_range(min_portion..max_portion)
77 };
78 accumulated += portion;
79
80 let day_offset = self.rng.gen_range(0..inbound_window);
81 let date = start_date + chrono::Duration::days(day_offset);
82 let timestamp = self.random_timestamp(date);
83
84 let (channel, category, counterparty) = self.random_inbound_source(i);
86
87 let txn = BankTransaction::new(
88 self.uuid_factory.next(),
89 account.account_id,
90 Decimal::from_f64_retain(portion).unwrap_or(Decimal::ZERO),
91 &account.currency,
92 Direction::Inbound,
93 channel,
94 category,
95 counterparty,
96 &format!("Transfer from source {}", i + 1),
97 timestamp,
98 )
99 .mark_suspicious(AmlTypology::FunnelAccount, &scenario_id)
100 .with_laundering_stage(LaunderingStage::Layering)
101 .with_scenario(&scenario_id, i as u32);
102
103 transactions.push(txn);
104 }
105
106 let outflow_start = start_date + chrono::Duration::days(inbound_window + hold_period);
108 let num_outbound = match sophistication {
109 Sophistication::Basic => 1..3,
110 Sophistication::Standard => 2..4,
111 Sophistication::Professional => 2..5,
112 Sophistication::Advanced => 3..6,
113 Sophistication::StateLevel => 4..8,
114 };
115
116 let num_out = self.rng.gen_range(num_outbound);
117 let mut remaining = total * 0.97; for i in 0..num_out {
120 let amount = if i == num_out - 1 {
121 remaining
122 } else {
123 let portion = remaining / ((num_out - i) as f64);
124 let variance = portion * 0.3;
125 self.rng
126 .gen_range((portion - variance)..(portion + variance))
127 };
128 remaining -= amount;
129
130 let day_offset = self.rng.gen_range(0..3) as i64;
131 let date = outflow_start + chrono::Duration::days(day_offset);
132 let timestamp = self.random_timestamp(date);
133
134 let (channel, category, counterparty) = self.random_outbound_destination(i);
135
136 let txn = BankTransaction::new(
137 self.uuid_factory.next(),
138 account.account_id,
139 Decimal::from_f64_retain(amount).unwrap_or(Decimal::ZERO),
140 &account.currency,
141 Direction::Outbound,
142 channel,
143 category,
144 counterparty,
145 &format!("Outward transfer {}", i + 1),
146 timestamp,
147 )
148 .mark_suspicious(AmlTypology::FunnelAccount, &scenario_id)
149 .with_laundering_stage(LaunderingStage::Layering)
150 .with_scenario(&scenario_id, (num_inbound + i) as u32);
151
152 transactions.push(txn);
153 }
154
155 if matches!(
157 sophistication,
158 Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
159 ) {
160 for txn in &mut transactions {
161 txn.is_spoofed = true;
162 txn.spoofing_intensity = Some(sophistication.spoofing_intensity());
163 }
164 }
165
166 transactions
167 }
168
169 fn random_inbound_source(
171 &mut self,
172 _index: usize,
173 ) -> (TransactionChannel, TransactionCategory, CounterpartyRef) {
174 let source_type = self.rng.gen_range(0..4);
175 match source_type {
176 0 => (
177 TransactionChannel::Ach,
178 TransactionCategory::TransferIn,
179 CounterpartyRef::person(&format!("Individual {}", self.rng.gen::<u16>())),
180 ),
181 1 => (
182 TransactionChannel::Wire,
183 TransactionCategory::TransferIn,
184 CounterpartyRef::business(&format!("Company {}", self.rng.gen::<u16>())),
185 ),
186 2 => (
187 TransactionChannel::Swift,
188 TransactionCategory::InternationalTransfer,
189 CounterpartyRef::international(&format!(
190 "Foreign Entity {}",
191 self.rng.gen::<u16>()
192 )),
193 ),
194 _ => (
195 TransactionChannel::Ach,
196 TransactionCategory::TransferIn,
197 CounterpartyRef::person(&format!("Sender {}", self.rng.gen::<u16>())),
198 ),
199 }
200 }
201
202 fn random_outbound_destination(
204 &mut self,
205 _index: usize,
206 ) -> (TransactionChannel, TransactionCategory, CounterpartyRef) {
207 let dest_type = self.rng.gen_range(0..3);
208 match dest_type {
209 0 => (
210 TransactionChannel::Swift,
211 TransactionCategory::InternationalTransfer,
212 CounterpartyRef::international(&format!(
213 "Offshore Account {}",
214 self.rng.gen::<u16>()
215 )),
216 ),
217 1 => (
218 TransactionChannel::Wire,
219 TransactionCategory::TransferOut,
220 CounterpartyRef::business(&format!("Shell Corp {}", self.rng.gen::<u16>())),
221 ),
222 _ => (
223 TransactionChannel::Atm,
224 TransactionCategory::AtmWithdrawal,
225 CounterpartyRef::atm("ATM"),
226 ),
227 }
228 }
229
230 fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
232 let hour: u32 = self.rng.gen_range(8..20);
233 let minute: u32 = self.rng.gen_range(0..60);
234 let second: u32 = self.rng.gen_range(0..60);
235
236 date.and_hms_opt(hour, minute, second)
237 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
238 .unwrap_or_else(Utc::now)
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use uuid::Uuid;
246
247 #[test]
248 fn test_funnel_generation() {
249 let mut injector = FunnelInjector::new(12345);
250
251 let customer = BankingCustomer::new_retail(
252 Uuid::new_v4(),
253 "Test",
254 "User",
255 "US",
256 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
257 );
258
259 let account = BankAccount::new(
260 Uuid::new_v4(),
261 "****1234".to_string(),
262 datasynth_core::models::banking::BankAccountType::Checking,
263 customer.customer_id,
264 "USD",
265 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
266 );
267
268 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
269 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
270
271 let transactions =
272 injector.generate(&customer, &account, start, end, Sophistication::Standard);
273
274 assert!(!transactions.is_empty());
275
276 let inbound: Vec<_> = transactions
278 .iter()
279 .filter(|t| t.direction == Direction::Inbound)
280 .collect();
281 let outbound: Vec<_> = transactions
282 .iter()
283 .filter(|t| t.direction == Direction::Outbound)
284 .collect();
285
286 assert!(!inbound.is_empty());
287 assert!(!outbound.is_empty());
288
289 for txn in &transactions {
291 assert!(txn.is_suspicious);
292 assert_eq!(txn.suspicion_reason, Some(AmlTypology::FunnelAccount));
293 }
294 }
295}