ringkernel_txmon/factory/
accounts.rs

1//! Customer account generation.
2
3use crate::types::{CustomerRiskLevel, CustomerRiskProfile};
4use rand::prelude::*;
5use rand::rngs::SmallRng;
6
7/// Generator for synthetic customer accounts.
8pub struct AccountGenerator {
9    rng: SmallRng,
10    next_id: u64,
11}
12
13impl AccountGenerator {
14    /// Create a new account generator with the given seed.
15    pub fn new(seed: u64) -> Self {
16        Self {
17            rng: SmallRng::seed_from_u64(seed),
18            next_id: 1,
19        }
20    }
21
22    /// Generate a batch of customer profiles.
23    pub fn generate_customers(&mut self, count: u32) -> Vec<CustomerRiskProfile> {
24        (0..count).map(|_| self.generate_customer()).collect()
25    }
26
27    /// Generate a single customer profile.
28    pub fn generate_customer(&mut self) -> CustomerRiskProfile {
29        let customer_id = self.next_id;
30        self.next_id += 1;
31
32        // Country distribution (weighted towards US)
33        let country_code = self.random_country();
34
35        let mut profile = CustomerRiskProfile::new(customer_id, country_code);
36
37        // Risk level distribution: 70% Low, 20% Medium, 8% High, 2% Prohibited
38        let risk_roll: f32 = self.rng.gen();
39        profile.risk_level = if risk_roll < 0.70 {
40            CustomerRiskLevel::Low as u8
41        } else if risk_roll < 0.90 {
42            CustomerRiskLevel::Medium as u8
43        } else if risk_roll < 0.98 {
44            CustomerRiskLevel::High as u8
45        } else {
46            CustomerRiskLevel::Prohibited as u8
47        };
48
49        // Risk score based on level
50        profile.risk_score = match CustomerRiskLevel::from_u8(profile.risk_level).unwrap() {
51            CustomerRiskLevel::Low => self.rng.gen_range(5..30),
52            CustomerRiskLevel::Medium => self.rng.gen_range(30..60),
53            CustomerRiskLevel::High => self.rng.gen_range(60..85),
54            CustomerRiskLevel::Prohibited => self.rng.gen_range(85..100),
55        };
56
57        // PEP status (1% chance) - but disabled by default to avoid unexpected alerts
58        // PEP alerts are triggered by suspicious_rate > 0 in the monitoring engine
59        profile.is_pep = 0; // PEP status now controlled by suspicious transactions only
60
61        // EDD requirement (5% chance, higher if high risk)
62        let edd_chance = if profile.risk_level >= CustomerRiskLevel::High as u8 {
63            0.30
64        } else {
65            0.05
66        };
67        profile.requires_edd = if self.rng.gen::<f32>() < edd_chance {
68            1
69        } else {
70            0
71        };
72
73        // Adverse media (2% chance)
74        profile.has_adverse_media = if self.rng.gen::<f32>() < 0.02 { 1 } else { 0 };
75
76        // Component risk scores
77        profile.geographic_risk = self.rng.gen_range(5..50);
78        profile.business_risk = self.rng.gen_range(5..50);
79        profile.behavioral_risk = self.rng.gen_range(5..50);
80
81        // Custom thresholds (most use defaults, some have custom)
82        if self.rng.gen::<f32>() < 0.1 {
83            // 10% have custom amount threshold
84            profile.amount_threshold = self.rng.gen_range(500_000..5_000_000);
85        }
86        if self.rng.gen::<f32>() < 0.1 {
87            // 10% have custom velocity threshold
88            profile.velocity_threshold = self.rng.gen_range(5..50);
89        }
90
91        // Allowed destinations - all countries allowed by default
92        // Destination restrictions would cause unexpected geographic anomaly alerts
93        // Geographic anomaly alerts are instead triggered by suspicious transaction patterns
94        profile.allowed_destinations = !0; // All countries allowed
95
96        // Average monthly volume
97        profile.avg_monthly_volume = self.rng.gen_range(100_000..50_000_000);
98
99        // Created timestamp (random time in past year)
100        let now_ms = std::time::SystemTime::now()
101            .duration_since(std::time::UNIX_EPOCH)
102            .unwrap()
103            .as_millis() as u64;
104        let one_year_ms = 365 * 24 * 60 * 60 * 1000;
105        profile.created_ts = now_ms - self.rng.gen_range(0..one_year_ms);
106
107        profile
108    }
109
110    /// Generate a random country code with weighted distribution.
111    fn random_country(&mut self) -> u16 {
112        // Weighted distribution: 60% US, 10% UK, 30% others
113        let roll: f32 = self.rng.gen();
114        if roll < 0.60 {
115            1 // US
116        } else if roll < 0.70 {
117            2 // UK
118        } else {
119            // Random from other countries
120            self.rng.gen_range(3..16)
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_generate_customers() {
131        let mut gen = AccountGenerator::new(42);
132        let customers = gen.generate_customers(100);
133
134        assert_eq!(customers.len(), 100);
135
136        // Check IDs are sequential
137        for (i, c) in customers.iter().enumerate() {
138            assert_eq!(c.customer_id, (i + 1) as u64);
139        }
140
141        // Check risk distribution (should have some variety)
142        let low_count = customers
143            .iter()
144            .filter(|c| c.risk_level == CustomerRiskLevel::Low as u8)
145            .count();
146        assert!(low_count > 50); // Should be majority low risk
147    }
148
149    #[test]
150    fn test_pep_disabled_by_default() {
151        let mut gen = AccountGenerator::new(42);
152        let customers = gen.generate_customers(1000);
153
154        let pep_count = customers.iter().filter(|c| c.is_pep != 0).count();
155        // PEP is now disabled by default to prevent unexpected alerts
156        assert_eq!(pep_count, 0);
157    }
158}