ringkernel_txmon/monitoring/
rules.rs

1//! Compliance monitoring rules.
2//!
3//! This module implements the core transaction monitoring logic, ported from
4//! the C# TransactionMonitoringKernel.
5
6use crate::types::{
7    AlertSeverity, AlertType, CustomerRiskLevel, CustomerRiskProfile, MonitoringAlert, Transaction,
8};
9
10/// Configuration for monitoring rules.
11#[derive(Debug, Clone)]
12pub struct MonitoringConfig {
13    /// Default amount threshold in cents ($10,000 = 1_000_000 cents).
14    pub amount_threshold: u64,
15    /// Default velocity threshold (transactions per window).
16    pub velocity_threshold: u32,
17    /// Structuring detection threshold percentage (e.g., 90 = 90% of amount threshold).
18    pub structuring_threshold_pct: u8,
19    /// Minimum velocity count for structuring detection.
20    pub structuring_min_velocity: u32,
21}
22
23impl Default for MonitoringConfig {
24    fn default() -> Self {
25        Self {
26            amount_threshold: 1_000_000, // $10,000 in cents
27            velocity_threshold: 10,      // 10 transactions per window
28            structuring_threshold_pct: 90,
29            structuring_min_velocity: 3,
30        }
31    }
32}
33
34/// Monitor a single transaction and generate any applicable alerts.
35///
36/// This is a direct port of the C# TransactionMonitoringKernel.MonitorTransaction logic:
37/// 1. Check velocity breach (too many transactions in time window)
38/// 2. Check amount threshold (single large transaction)
39/// 3. Check structured transactions (smurfing - amounts just under threshold)
40/// 4. Check geographic anomaly (unusual destination country)
41pub fn monitor_transaction(
42    tx: &Transaction,
43    profile: &CustomerRiskProfile,
44    config: &MonitoringConfig,
45    alert_id_start: u64,
46) -> Vec<MonitoringAlert> {
47    let mut alerts = Vec::new();
48    let mut alert_id = alert_id_start;
49
50    // Determine thresholds (customer-specific or default)
51    let amount_threshold = if profile.amount_threshold > 0 {
52        profile.amount_threshold
53    } else {
54        config.amount_threshold
55    };
56
57    let velocity_threshold = if profile.velocity_threshold > 0 {
58        profile.velocity_threshold
59    } else {
60        config.velocity_threshold
61    };
62
63    // 1. Velocity breach check
64    if profile.velocity_count > velocity_threshold {
65        let severity = calculate_velocity_severity(profile.velocity_count, velocity_threshold);
66        let mut alert = MonitoringAlert::new(
67            alert_id,
68            tx.transaction_id,
69            tx.customer_id,
70            AlertType::VelocityBreach,
71            severity,
72            tx.amount_cents,
73            tx.timestamp,
74        );
75        alert.velocity_count = profile.velocity_count;
76        alert.risk_score = calculate_risk_score(profile, &AlertType::VelocityBreach);
77        alerts.push(alert);
78        alert_id += 1;
79    }
80
81    // 2. Amount threshold check
82    if tx.amount_cents > amount_threshold {
83        let severity = calculate_amount_severity(tx.amount_cents, amount_threshold);
84        let mut alert = MonitoringAlert::new(
85            alert_id,
86            tx.transaction_id,
87            tx.customer_id,
88            AlertType::AmountThreshold,
89            severity,
90            tx.amount_cents,
91            tx.timestamp,
92        );
93        alert.risk_score = calculate_risk_score(profile, &AlertType::AmountThreshold);
94        alerts.push(alert);
95        alert_id += 1;
96    }
97
98    // 3. Structured transaction (smurfing) check
99    // Amount > 90% of threshold AND velocity >= 3
100    let structuring_threshold = (amount_threshold * config.structuring_threshold_pct as u64) / 100;
101
102    if tx.amount_cents > structuring_threshold
103        && tx.amount_cents <= amount_threshold
104        && profile.velocity_count >= config.structuring_min_velocity
105    {
106        let mut alert = MonitoringAlert::new(
107            alert_id,
108            tx.transaction_id,
109            tx.customer_id,
110            AlertType::StructuredTransaction,
111            AlertSeverity::High,
112            tx.amount_cents,
113            tx.timestamp,
114        );
115        alert.velocity_count = profile.velocity_count;
116        alert.risk_score = calculate_risk_score(profile, &AlertType::StructuredTransaction);
117        alerts.push(alert);
118        alert_id += 1;
119    }
120
121    // 4. Geographic anomaly check
122    if tx.country_code != profile.country_code {
123        let is_allowed = profile.is_destination_allowed(tx.country_code);
124        if !is_allowed {
125            let severity = if is_high_risk_country(tx.country_code) {
126                AlertSeverity::High
127            } else {
128                AlertSeverity::Medium
129            };
130
131            let mut alert = MonitoringAlert::new(
132                alert_id,
133                tx.transaction_id,
134                tx.customer_id,
135                AlertType::GeographicAnomaly,
136                severity,
137                tx.amount_cents,
138                tx.timestamp,
139            );
140            alert.country_code = tx.country_code;
141            alert.risk_score = calculate_risk_score(profile, &AlertType::GeographicAnomaly);
142            alerts.push(alert);
143            alert_id += 1;
144        }
145    }
146
147    // 5. PEP-related check (any transaction from PEP gets flagged)
148    if profile.is_pep() && tx.amount_cents > 500_000 {
149        // Flag PEP transactions over $5,000
150        let mut alert = MonitoringAlert::new(
151            alert_id,
152            tx.transaction_id,
153            tx.customer_id,
154            AlertType::PEPRelated,
155            AlertSeverity::High,
156            tx.amount_cents,
157            tx.timestamp,
158        );
159        alert.risk_score = calculate_risk_score(profile, &AlertType::PEPRelated);
160        alerts.push(alert);
161        // alert_id += 1; // Unused but kept for consistency
162    }
163
164    alerts
165}
166
167/// Calculate severity based on how much velocity exceeds threshold.
168fn calculate_velocity_severity(velocity: u32, threshold: u32) -> AlertSeverity {
169    let ratio = velocity as f32 / threshold as f32;
170    if ratio >= 3.0 {
171        AlertSeverity::Critical
172    } else if ratio >= 2.0 {
173        AlertSeverity::High
174    } else {
175        AlertSeverity::Medium
176    }
177}
178
179/// Calculate severity based on how much amount exceeds threshold.
180fn calculate_amount_severity(amount: u64, threshold: u64) -> AlertSeverity {
181    let ratio = amount as f64 / threshold as f64;
182    if ratio >= 5.0 {
183        AlertSeverity::Critical
184    } else if ratio >= 2.0 {
185        AlertSeverity::High
186    } else {
187        AlertSeverity::Medium
188    }
189}
190
191/// Calculate risk score based on customer profile and alert type.
192fn calculate_risk_score(profile: &CustomerRiskProfile, alert_type: &AlertType) -> u32 {
193    let base_score = profile.risk_score as u32;
194
195    // Add modifiers based on alert type
196    let type_modifier = match alert_type {
197        AlertType::SanctionsHit => 50,
198        AlertType::PEPRelated => 30,
199        AlertType::StructuredTransaction => 25,
200        AlertType::VelocityBreach => 20,
201        AlertType::AmountThreshold => 15,
202        AlertType::GeographicAnomaly => 15,
203        _ => 10,
204    };
205
206    // Add modifier for customer risk level
207    let risk_modifier = match profile.risk_level() {
208        CustomerRiskLevel::Prohibited => 30,
209        CustomerRiskLevel::High => 20,
210        CustomerRiskLevel::Medium => 10,
211        CustomerRiskLevel::Low => 0,
212    };
213
214    // PEP and EDD modifiers
215    let pep_modifier = if profile.is_pep() { 15 } else { 0 };
216    let edd_modifier = if profile.requires_edd() { 10 } else { 0 };
217
218    // Cap at 100
219    (base_score + type_modifier + risk_modifier + pep_modifier + edd_modifier).min(100)
220}
221
222/// Check if a country code is considered high-risk.
223fn is_high_risk_country(country_code: u16) -> bool {
224    // Simplified high-risk country list (using our arbitrary codes)
225    // 7 = RU, 6 = CN in our simplified mapping
226    matches!(country_code, 6 | 7)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn make_customer(velocity: u32, country: u16) -> CustomerRiskProfile {
234        let mut p = CustomerRiskProfile::new(1, country);
235        p.velocity_count = velocity;
236        p
237    }
238
239    fn make_transaction(amount_cents: u64, country: u16) -> Transaction {
240        Transaction::new(1, 1, amount_cents, country, 0)
241    }
242
243    #[test]
244    fn test_velocity_breach() {
245        let config = MonitoringConfig::default();
246        let customer = make_customer(15, 1); // 15 transactions, threshold is 10
247        let tx = make_transaction(10_000, 1);
248
249        let alerts = monitor_transaction(&tx, &customer, &config, 1);
250
251        assert!(alerts
252            .iter()
253            .any(|a| a.alert_type() == AlertType::VelocityBreach));
254    }
255
256    #[test]
257    fn test_no_velocity_breach() {
258        let config = MonitoringConfig::default();
259        let customer = make_customer(5, 1); // 5 transactions, under threshold
260        let tx = make_transaction(10_000, 1);
261
262        let alerts = monitor_transaction(&tx, &customer, &config, 1);
263
264        assert!(!alerts
265            .iter()
266            .any(|a| a.alert_type() == AlertType::VelocityBreach));
267    }
268
269    #[test]
270    fn test_amount_threshold() {
271        let config = MonitoringConfig::default();
272        let customer = make_customer(0, 1);
273        let tx = make_transaction(1_500_000, 1); // $15,000, over threshold
274
275        let alerts = monitor_transaction(&tx, &customer, &config, 1);
276
277        assert!(alerts
278            .iter()
279            .any(|a| a.alert_type() == AlertType::AmountThreshold));
280    }
281
282    #[test]
283    fn test_structured_transaction() {
284        let config = MonitoringConfig::default();
285        let customer = make_customer(5, 1); // 5 transactions (>= min_velocity of 3)
286        let tx = make_transaction(950_000, 1); // $9,500 (95% of threshold)
287
288        let alerts = monitor_transaction(&tx, &customer, &config, 1);
289
290        assert!(alerts
291            .iter()
292            .any(|a| a.alert_type() == AlertType::StructuredTransaction));
293    }
294
295    #[test]
296    fn test_no_structured_without_velocity() {
297        let config = MonitoringConfig::default();
298        let customer = make_customer(1, 1); // Only 1 transaction
299        let tx = make_transaction(950_000, 1); // $9,500
300
301        let alerts = monitor_transaction(&tx, &customer, &config, 1);
302
303        // Should not trigger structuring because velocity is too low
304        assert!(!alerts
305            .iter()
306            .any(|a| a.alert_type() == AlertType::StructuredTransaction));
307    }
308
309    #[test]
310    fn test_geographic_anomaly() {
311        let config = MonitoringConfig::default();
312        let mut customer = make_customer(0, 1); // US customer
313        customer.allowed_destinations = 0b11; // Only countries 0 and 1 allowed
314        let tx = make_transaction(500_000, 7); // Transaction to country 7 (not allowed)
315
316        let alerts = monitor_transaction(&tx, &customer, &config, 1);
317
318        assert!(alerts
319            .iter()
320            .any(|a| a.alert_type() == AlertType::GeographicAnomaly));
321    }
322
323    #[test]
324    fn test_severity_calculation() {
325        assert_eq!(calculate_velocity_severity(30, 10), AlertSeverity::Critical); // 3x
326        assert_eq!(calculate_velocity_severity(20, 10), AlertSeverity::High); // 2x
327        assert_eq!(calculate_velocity_severity(15, 10), AlertSeverity::Medium); // 1.5x
328    }
329}