datasynth_banking/typologies/
structuring.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 StructuringInjector {
17 rng: ChaCha8Rng,
18 uuid_factory: DeterministicUuidFactory,
19}
20
21impl StructuringInjector {
22 pub fn new(seed: u64) -> Self {
24 Self {
25 rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(6000)),
26 uuid_factory: DeterministicUuidFactory::new(
27 seed,
28 datasynth_core::GeneratorType::Anomaly,
29 ),
30 }
31 }
32
33 pub fn generate(
35 &mut self,
36 _customer: &BankingCustomer,
37 account: &BankAccount,
38 start_date: NaiveDate,
39 end_date: NaiveDate,
40 sophistication: Sophistication,
41 ) -> Vec<BankTransaction> {
42 let mut transactions = Vec::new();
43
44 let threshold = 10_000.0;
46 let total_amount: f64 = self.rng.gen_range(30_000.0..100_000.0);
47
48 let num_deposits = match sophistication {
50 Sophistication::Basic => self.rng.gen_range(3..6),
51 Sophistication::Standard => self.rng.gen_range(5..10),
52 Sophistication::Professional => self.rng.gen_range(8..15),
53 Sophistication::Advanced => self.rng.gen_range(12..25),
54 Sophistication::StateLevel => self.rng.gen_range(20..40),
55 };
56
57 let days_spread = match sophistication {
59 Sophistication::Basic => 3,
60 Sophistication::Standard => 7,
61 Sophistication::Professional => 14,
62 Sophistication::Advanced => 30,
63 Sophistication::StateLevel => 60,
64 };
65
66 let available_days = (end_date - start_date).num_days().max(1) as u32;
67 let actual_spread = days_spread.min(available_days);
68
69 let mut remaining = total_amount;
70 let scenario_id = format!("STR-{:06}", self.rng.gen::<u32>());
71
72 for i in 0..num_deposits {
73 if remaining <= 0.0 {
74 break;
75 }
76
77 let max_deposit = threshold * 0.99;
79 let min_deposit = threshold * 0.80;
80 let deposit_amount = if remaining > max_deposit {
81 self.rng.gen_range(min_deposit..max_deposit)
82 } else {
83 remaining.min(max_deposit)
84 };
85
86 remaining -= deposit_amount;
87
88 let day_offset = if actual_spread > 0 {
90 self.rng.gen_range(0..actual_spread) as i64
91 } else {
92 0
93 };
94 let date = start_date + chrono::Duration::days(day_offset);
95 let timestamp = self.random_timestamp(date);
96
97 let txn = BankTransaction::new(
98 self.uuid_factory.next(),
99 account.account_id,
100 Decimal::from_f64_retain(deposit_amount).unwrap_or(Decimal::ZERO),
101 &account.currency,
102 Direction::Inbound,
103 TransactionChannel::Cash,
104 TransactionCategory::CashDeposit,
105 CounterpartyRef::atm("Branch"),
106 &format!("Cash deposit #{}", i + 1),
107 timestamp,
108 )
109 .mark_suspicious(AmlTypology::Structuring, &scenario_id)
110 .with_laundering_stage(LaunderingStage::Placement)
111 .with_scenario(&scenario_id, i as u32);
112
113 transactions.push(txn);
114 }
115
116 if matches!(
118 sophistication,
119 Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
120 ) {
121 for txn in &mut transactions {
122 txn.is_spoofed = true;
123 txn.spoofing_intensity = Some(sophistication.spoofing_intensity());
124 }
125 }
126
127 transactions
128 }
129
130 fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
132 let hour: u32 = self.rng.gen_range(9..17); let minute: u32 = self.rng.gen_range(0..60);
134 let second: u32 = self.rng.gen_range(0..60);
135
136 date.and_hms_opt(hour, minute, second)
137 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
138 .unwrap_or_else(Utc::now)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use uuid::Uuid;
146
147 #[test]
148 fn test_structuring_generation() {
149 let mut injector = StructuringInjector::new(12345);
150
151 let customer = BankingCustomer::new_retail(
152 Uuid::new_v4(),
153 "Test",
154 "User",
155 "US",
156 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
157 );
158
159 let account = BankAccount::new(
160 Uuid::new_v4(),
161 "****1234".to_string(),
162 datasynth_core::models::banking::BankAccountType::Checking,
163 customer.customer_id,
164 "USD",
165 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
166 );
167
168 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
169 let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
170
171 let transactions =
172 injector.generate(&customer, &account, start, end, Sophistication::Basic);
173
174 assert!(!transactions.is_empty());
175
176 for txn in &transactions {
178 assert!(txn.is_suspicious);
179 assert_eq!(txn.suspicion_reason, Some(AmlTypology::Structuring));
180 let amount_f64: f64 = txn.amount.try_into().unwrap();
182 assert!(amount_f64 < 10_000.0);
183 }
184 }
185}