Skip to main content

datasynth_banking/typologies/
funnel.rs

1//! Funnel account 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/// Funnel account pattern injector.
16///
17/// Funnel accounts show:
18/// - Many unrelated inbound transfers from different sources
19/// - Rapid consolidation and outward movement
20/// - Short holding periods
21/// - High velocity relative to account age
22pub struct FunnelInjector {
23    rng: ChaCha8Rng,
24    uuid_factory: DeterministicUuidFactory,
25}
26
27impl FunnelInjector {
28    /// Create a new funnel injector.
29    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    /// Generate funnel account transactions.
40    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        // Funnel parameters based on sophistication
51        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        // Phase 1: Inbound transfers from multiple sources
67        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            // Vary the source types
85            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        // Phase 2: Consolidation outflow after holding period
107        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; // Account for fees
118
119        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        // Apply spoofing for sophisticated patterns
156        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    /// Generate random inbound source.
170    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    /// Generate random outbound destination.
203    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    /// Generate random timestamp for a date.
231    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        // Should have mix of inbound and outbound
277        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        // All should be marked as funnel account
290        for txn in &transactions {
291            assert!(txn.is_suspicious);
292            assert_eq!(txn.suspicion_reason, Some(AmlTypology::FunnelAccount));
293        }
294    }
295}