Skip to main content

datasynth_banking/models/
kyc_profile.rs

1//! KYC profile model for expected activity envelope.
2
3use datasynth_core::models::banking::{
4    CashIntensity, CountryExposure, FrequencyBand, SourceOfFunds, SourceOfWealth, TurnoverBand,
5};
6use serde::{Deserialize, Serialize};
7
8/// KYC profile defining expected customer activity.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct KycProfile {
11    /// Declared purpose of the account
12    pub declared_purpose: String,
13    /// Expected monthly turnover band
14    pub expected_monthly_turnover: TurnoverBand,
15    /// Expected transaction frequency band
16    pub expected_transaction_frequency: FrequencyBand,
17    /// Expected merchant/transaction categories
18    pub expected_categories: Vec<ExpectedCategory>,
19    /// Declared source of funds
20    pub source_of_funds: SourceOfFunds,
21    /// Declared source of wealth (for HNW)
22    pub source_of_wealth: Option<SourceOfWealth>,
23    /// Geographic exposure (countries)
24    pub geographic_exposure: Vec<CountryExposure>,
25    /// Expected cash intensity
26    pub cash_intensity: CashIntensity,
27    /// Beneficial owner complexity score (0-10)
28    pub beneficial_owner_complexity: u8,
29    /// Expected international transaction rate (0.0-1.0)
30    pub international_rate: f64,
31    /// Expected large transaction rate (0.0-1.0)
32    pub large_transaction_rate: f64,
33    /// Threshold for "large" transaction
34    pub large_transaction_threshold: u64,
35    /// KYC completeness score (0.0-1.0)
36    pub completeness_score: f64,
37
38    // Ground truth (for deception modeling)
39    /// True source of funds (if different from declared)
40    pub true_source_of_funds: Option<SourceOfFunds>,
41    /// True expected turnover (if different from declared)
42    pub true_turnover: Option<TurnoverBand>,
43    /// Whether the KYC profile is truthful
44    pub is_truthful: bool,
45}
46
47impl Default for KycProfile {
48    fn default() -> Self {
49        Self {
50            declared_purpose: "Personal banking".to_string(),
51            expected_monthly_turnover: TurnoverBand::default(),
52            expected_transaction_frequency: FrequencyBand::default(),
53            expected_categories: Vec::new(),
54            source_of_funds: SourceOfFunds::Employment,
55            source_of_wealth: None,
56            geographic_exposure: Vec::new(),
57            cash_intensity: CashIntensity::default(),
58            beneficial_owner_complexity: 0,
59            international_rate: 0.05,
60            large_transaction_rate: 0.02,
61            large_transaction_threshold: 10_000,
62            completeness_score: 1.0,
63            true_source_of_funds: None,
64            true_turnover: None,
65            is_truthful: true,
66        }
67    }
68}
69
70impl KycProfile {
71    /// Create a new KYC profile.
72    pub fn new(purpose: &str, source_of_funds: SourceOfFunds) -> Self {
73        Self {
74            declared_purpose: purpose.to_string(),
75            source_of_funds,
76            ..Default::default()
77        }
78    }
79
80    /// Create a profile for a retail customer.
81    pub fn retail_standard() -> Self {
82        Self {
83            declared_purpose: "Personal checking and savings".to_string(),
84            expected_monthly_turnover: TurnoverBand::Low,
85            expected_transaction_frequency: FrequencyBand::Medium,
86            source_of_funds: SourceOfFunds::Employment,
87            cash_intensity: CashIntensity::Low,
88            ..Default::default()
89        }
90    }
91
92    /// Create a profile for a high net worth customer.
93    pub fn high_net_worth() -> Self {
94        Self {
95            declared_purpose: "Wealth management and investment".to_string(),
96            expected_monthly_turnover: TurnoverBand::VeryHigh,
97            expected_transaction_frequency: FrequencyBand::High,
98            source_of_funds: SourceOfFunds::Investments,
99            source_of_wealth: Some(SourceOfWealth::BusinessOwnership),
100            cash_intensity: CashIntensity::VeryLow,
101            international_rate: 0.20,
102            large_transaction_rate: 0.15,
103            large_transaction_threshold: 50_000,
104            ..Default::default()
105        }
106    }
107
108    /// Create a profile for a small business.
109    pub fn small_business() -> Self {
110        Self {
111            declared_purpose: "Business operations".to_string(),
112            expected_monthly_turnover: TurnoverBand::Medium,
113            expected_transaction_frequency: FrequencyBand::High,
114            source_of_funds: SourceOfFunds::SelfEmployment,
115            cash_intensity: CashIntensity::Moderate,
116            large_transaction_rate: 0.05,
117            large_transaction_threshold: 25_000,
118            ..Default::default()
119        }
120    }
121
122    /// Create a profile for a cash-intensive business.
123    pub fn cash_intensive_business() -> Self {
124        Self {
125            declared_purpose: "Retail business operations".to_string(),
126            expected_monthly_turnover: TurnoverBand::High,
127            expected_transaction_frequency: FrequencyBand::VeryHigh,
128            source_of_funds: SourceOfFunds::SelfEmployment,
129            cash_intensity: CashIntensity::VeryHigh,
130            large_transaction_rate: 0.01,
131            large_transaction_threshold: 10_000,
132            ..Default::default()
133        }
134    }
135
136    /// Set turnover band.
137    pub fn with_turnover(mut self, turnover: TurnoverBand) -> Self {
138        self.expected_monthly_turnover = turnover;
139        self
140    }
141
142    /// Set frequency band.
143    pub fn with_frequency(mut self, frequency: FrequencyBand) -> Self {
144        self.expected_transaction_frequency = frequency;
145        self
146    }
147
148    /// Add expected category.
149    pub fn with_expected_category(mut self, category: ExpectedCategory) -> Self {
150        self.expected_categories.push(category);
151        self
152    }
153
154    /// Add geographic exposure.
155    pub fn with_country_exposure(mut self, exposure: CountryExposure) -> Self {
156        self.geographic_exposure.push(exposure);
157        self
158    }
159
160    /// Set cash intensity.
161    pub fn with_cash_intensity(mut self, intensity: CashIntensity) -> Self {
162        self.cash_intensity = intensity;
163        self
164    }
165
166    /// Set as deceptive (ground truth differs from declared).
167    pub fn with_deception(
168        mut self,
169        true_source: SourceOfFunds,
170        true_turnover: Option<TurnoverBand>,
171    ) -> Self {
172        self.true_source_of_funds = Some(true_source);
173        self.true_turnover = true_turnover;
174        self.is_truthful = false;
175        self
176    }
177
178    /// Calculate risk score based on profile.
179    pub fn calculate_risk_score(&self) -> u8 {
180        let mut score = 0.0;
181
182        // Source of funds risk
183        score += self.source_of_funds.risk_weight() * 15.0;
184
185        // Turnover risk (higher turnover = higher risk)
186        let (_, max_turnover) = self.expected_monthly_turnover.range();
187        if max_turnover > 100_000 {
188            score += 15.0;
189        } else if max_turnover > 25_000 {
190            score += 10.0;
191        } else if max_turnover > 5_000 {
192            score += 5.0;
193        }
194
195        // Cash intensity risk
196        score += self.cash_intensity.risk_weight() * 10.0;
197
198        // International exposure risk
199        score += self.international_rate * 20.0;
200
201        // UBO complexity risk
202        score += (self.beneficial_owner_complexity as f64) * 2.0;
203
204        // Deception risk (if ground truth available)
205        if !self.is_truthful {
206            score += 25.0;
207        }
208
209        // Completeness penalty
210        score += (1.0 - self.completeness_score) * 10.0;
211
212        score.min(100.0) as u8
213    }
214
215    /// Check if actual activity matches expected.
216    pub fn is_within_expected_turnover(&self, actual_monthly: u64) -> bool {
217        let (min, max) = self.expected_monthly_turnover.range();
218        actual_monthly >= min && actual_monthly <= max * 2 // Allow some tolerance
219    }
220
221    /// Check if transaction frequency is within expected.
222    pub fn is_within_expected_frequency(&self, actual_count: u32) -> bool {
223        let (min, max) = self.expected_transaction_frequency.range();
224        actual_count >= min / 2 && actual_count <= max * 2
225    }
226}
227
228/// Expected transaction category with weight.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ExpectedCategory {
231    /// Category name
232    pub category: String,
233    /// Expected percentage of transactions (0.0-1.0)
234    pub expected_percentage: f64,
235    /// Tolerance for deviation
236    pub tolerance: f64,
237}
238
239impl ExpectedCategory {
240    /// Create a new expected category.
241    pub fn new(category: &str, percentage: f64) -> Self {
242        Self {
243            category: category.to_string(),
244            expected_percentage: percentage,
245            tolerance: 0.1, // 10% tolerance
246        }
247    }
248
249    /// Check if actual matches expected.
250    pub fn matches(&self, actual_percentage: f64) -> bool {
251        (actual_percentage - self.expected_percentage).abs() <= self.tolerance
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_kyc_profile_default() {
261        let profile = KycProfile::default();
262        assert!(profile.is_truthful);
263        assert_eq!(profile.source_of_funds, SourceOfFunds::Employment);
264    }
265
266    #[test]
267    fn test_kyc_profile_presets() {
268        let retail = KycProfile::retail_standard();
269        assert_eq!(retail.expected_monthly_turnover, TurnoverBand::Low);
270
271        let hnw = KycProfile::high_net_worth();
272        assert_eq!(hnw.expected_monthly_turnover, TurnoverBand::VeryHigh);
273        assert!(hnw.source_of_wealth.is_some());
274    }
275
276    #[test]
277    fn test_deceptive_profile() {
278        let profile = KycProfile::retail_standard()
279            .with_deception(SourceOfFunds::CryptoAssets, Some(TurnoverBand::VeryHigh));
280
281        assert!(!profile.is_truthful);
282        assert!(profile.true_source_of_funds.is_some());
283
284        let base_score = KycProfile::retail_standard().calculate_risk_score();
285        let deceptive_score = profile.calculate_risk_score();
286        assert!(deceptive_score > base_score);
287    }
288
289    #[test]
290    fn test_turnover_check() {
291        let profile = KycProfile::default().with_turnover(TurnoverBand::Medium);
292        // Medium is 5,000 - 25,000
293        assert!(profile.is_within_expected_turnover(10_000));
294        assert!(profile.is_within_expected_turnover(40_000)); // Within 2x tolerance
295        assert!(!profile.is_within_expected_turnover(100_000));
296    }
297}