use datasynth_core::models::banking::{
CashIntensity, CountryExposure, FrequencyBand, SourceOfFunds, SourceOfWealth, TurnoverBand,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KycProfile {
pub declared_purpose: String,
pub expected_monthly_turnover: TurnoverBand,
pub expected_transaction_frequency: FrequencyBand,
pub expected_categories: Vec<ExpectedCategory>,
pub source_of_funds: SourceOfFunds,
pub source_of_wealth: Option<SourceOfWealth>,
pub geographic_exposure: Vec<CountryExposure>,
pub cash_intensity: CashIntensity,
pub beneficial_owner_complexity: u8,
pub international_rate: f64,
pub large_transaction_rate: f64,
pub large_transaction_threshold: u64,
pub completeness_score: f64,
pub true_source_of_funds: Option<SourceOfFunds>,
pub true_turnover: Option<TurnoverBand>,
pub is_truthful: bool,
}
impl Default for KycProfile {
fn default() -> Self {
Self {
declared_purpose: "Personal banking".to_string(),
expected_monthly_turnover: TurnoverBand::default(),
expected_transaction_frequency: FrequencyBand::default(),
expected_categories: Vec::new(),
source_of_funds: SourceOfFunds::Employment,
source_of_wealth: None,
geographic_exposure: Vec::new(),
cash_intensity: CashIntensity::default(),
beneficial_owner_complexity: 0,
international_rate: 0.05,
large_transaction_rate: 0.02,
large_transaction_threshold: 10_000,
completeness_score: 1.0,
true_source_of_funds: None,
true_turnover: None,
is_truthful: true,
}
}
}
impl KycProfile {
pub fn new(purpose: &str, source_of_funds: SourceOfFunds) -> Self {
Self {
declared_purpose: purpose.to_string(),
source_of_funds,
..Default::default()
}
}
pub fn retail_standard() -> Self {
Self {
declared_purpose: "Personal checking and savings".to_string(),
expected_monthly_turnover: TurnoverBand::Low,
expected_transaction_frequency: FrequencyBand::Medium,
source_of_funds: SourceOfFunds::Employment,
cash_intensity: CashIntensity::Low,
..Default::default()
}
}
pub fn high_net_worth() -> Self {
Self {
declared_purpose: "Wealth management and investment".to_string(),
expected_monthly_turnover: TurnoverBand::VeryHigh,
expected_transaction_frequency: FrequencyBand::High,
source_of_funds: SourceOfFunds::Investments,
source_of_wealth: Some(SourceOfWealth::BusinessOwnership),
cash_intensity: CashIntensity::VeryLow,
international_rate: 0.20,
large_transaction_rate: 0.15,
large_transaction_threshold: 50_000,
..Default::default()
}
}
pub fn small_business() -> Self {
Self {
declared_purpose: "Business operations".to_string(),
expected_monthly_turnover: TurnoverBand::Medium,
expected_transaction_frequency: FrequencyBand::High,
source_of_funds: SourceOfFunds::SelfEmployment,
cash_intensity: CashIntensity::Moderate,
large_transaction_rate: 0.05,
large_transaction_threshold: 25_000,
..Default::default()
}
}
pub fn cash_intensive_business() -> Self {
Self {
declared_purpose: "Retail business operations".to_string(),
expected_monthly_turnover: TurnoverBand::High,
expected_transaction_frequency: FrequencyBand::VeryHigh,
source_of_funds: SourceOfFunds::SelfEmployment,
cash_intensity: CashIntensity::VeryHigh,
large_transaction_rate: 0.01,
large_transaction_threshold: 10_000,
..Default::default()
}
}
pub fn with_turnover(mut self, turnover: TurnoverBand) -> Self {
self.expected_monthly_turnover = turnover;
self
}
pub fn with_frequency(mut self, frequency: FrequencyBand) -> Self {
self.expected_transaction_frequency = frequency;
self
}
pub fn with_expected_category(mut self, category: ExpectedCategory) -> Self {
self.expected_categories.push(category);
self
}
pub fn with_country_exposure(mut self, exposure: CountryExposure) -> Self {
self.geographic_exposure.push(exposure);
self
}
pub fn with_cash_intensity(mut self, intensity: CashIntensity) -> Self {
self.cash_intensity = intensity;
self
}
pub fn with_deception(
mut self,
true_source: SourceOfFunds,
true_turnover: Option<TurnoverBand>,
) -> Self {
self.true_source_of_funds = Some(true_source);
self.true_turnover = true_turnover;
self.is_truthful = false;
self
}
pub fn calculate_risk_score(&self) -> u8 {
let mut score = 0.0;
score += self.source_of_funds.risk_weight() * 15.0;
let (_, max_turnover) = self.expected_monthly_turnover.range();
if max_turnover > 100_000 {
score += 15.0;
} else if max_turnover > 25_000 {
score += 10.0;
} else if max_turnover > 5_000 {
score += 5.0;
}
score += self.cash_intensity.risk_weight() * 10.0;
score += self.international_rate * 20.0;
score += (self.beneficial_owner_complexity as f64) * 2.0;
if !self.is_truthful {
score += 25.0;
}
score += (1.0 - self.completeness_score) * 10.0;
score.min(100.0) as u8
}
pub fn is_within_expected_turnover(&self, actual_monthly: u64) -> bool {
let (min, max) = self.expected_monthly_turnover.range();
actual_monthly >= min && actual_monthly <= max * 2 }
pub fn is_within_expected_frequency(&self, actual_count: u32) -> bool {
let (min, max) = self.expected_transaction_frequency.range();
actual_count >= min / 2 && actual_count <= max * 2
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpectedCategory {
pub category: String,
pub expected_percentage: f64,
pub tolerance: f64,
}
impl ExpectedCategory {
pub fn new(category: &str, percentage: f64) -> Self {
Self {
category: category.to_string(),
expected_percentage: percentage,
tolerance: 0.1, }
}
pub fn matches(&self, actual_percentage: f64) -> bool {
(actual_percentage - self.expected_percentage).abs() <= self.tolerance
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_kyc_profile_default() {
let profile = KycProfile::default();
assert!(profile.is_truthful);
assert_eq!(profile.source_of_funds, SourceOfFunds::Employment);
}
#[test]
fn test_kyc_profile_presets() {
let retail = KycProfile::retail_standard();
assert_eq!(retail.expected_monthly_turnover, TurnoverBand::Low);
let hnw = KycProfile::high_net_worth();
assert_eq!(hnw.expected_monthly_turnover, TurnoverBand::VeryHigh);
assert!(hnw.source_of_wealth.is_some());
}
#[test]
fn test_deceptive_profile() {
let profile = KycProfile::retail_standard()
.with_deception(SourceOfFunds::CryptoAssets, Some(TurnoverBand::VeryHigh));
assert!(!profile.is_truthful);
assert!(profile.true_source_of_funds.is_some());
let base_score = KycProfile::retail_standard().calculate_risk_score();
let deceptive_score = profile.calculate_risk_score();
assert!(deceptive_score > base_score);
}
#[test]
fn test_turnover_check() {
let profile = KycProfile::default().with_turnover(TurnoverBand::Medium);
assert!(profile.is_within_expected_turnover(10_000));
assert!(profile.is_within_expected_turnover(40_000)); assert!(!profile.is_within_expected_turnover(100_000));
}
}