Skip to main content

datasynth_banking/typologies/
structuring.rs

1//! Structuring (smurfing) typology implementation.
2
3use 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
15/// Structuring pattern injector.
16pub struct StructuringInjector {
17    rng: ChaCha8Rng,
18    uuid_factory: DeterministicUuidFactory,
19}
20
21impl StructuringInjector {
22    /// Create a new structuring injector.
23    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    /// Generate structuring transactions.
34    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        // Structuring: multiple deposits just below $10,000 threshold
45        let threshold = 10_000.0;
46        let total_amount: f64 = self.rng.gen_range(30_000.0..100_000.0);
47
48        // Number of deposits based on sophistication
49        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        // Time spread based on sophistication
58        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            // Amount just below threshold with some variation
78            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            // Time distribution
89            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        // Apply spoofing for sophisticated patterns
117        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    /// Generate random timestamp for a date.
131    fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
132        let hour: u32 = self.rng.gen_range(9..17); // Business hours
133        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        // All should be suspicious structuring
177        for txn in &transactions {
178            assert!(txn.is_suspicious);
179            assert_eq!(txn.suspicion_reason, Some(AmlTypology::Structuring));
180            // Amount should be below $10k
181            let amount_f64: f64 = txn.amount.try_into().unwrap();
182            assert!(amount_f64 < 10_000.0);
183        }
184    }
185}