ringkernel_txmon/monitoring/
rules.rs1use crate::types::{
7 AlertSeverity, AlertType, CustomerRiskLevel, CustomerRiskProfile, MonitoringAlert, Transaction,
8};
9
10#[derive(Debug, Clone)]
12pub struct MonitoringConfig {
13 pub amount_threshold: u64,
15 pub velocity_threshold: u32,
17 pub structuring_threshold_pct: u8,
19 pub structuring_min_velocity: u32,
21}
22
23impl Default for MonitoringConfig {
24 fn default() -> Self {
25 Self {
26 amount_threshold: 1_000_000, velocity_threshold: 10, structuring_threshold_pct: 90,
29 structuring_min_velocity: 3,
30 }
31 }
32}
33
34pub 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 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 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 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 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 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 if profile.is_pep() && tx.amount_cents > 500_000 {
149 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 }
163
164 alerts
165}
166
167fn 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
179fn 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
191fn calculate_risk_score(profile: &CustomerRiskProfile, alert_type: &AlertType) -> u32 {
193 let base_score = profile.risk_score as u32;
194
195 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 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 let pep_modifier = if profile.is_pep() { 15 } else { 0 };
216 let edd_modifier = if profile.requires_edd() { 10 } else { 0 };
217
218 (base_score + type_modifier + risk_modifier + pep_modifier + edd_modifier).min(100)
220}
221
222fn is_high_risk_country(country_code: u16) -> bool {
224 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); 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); 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); 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); let tx = make_transaction(950_000, 1); 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); let tx = make_transaction(950_000, 1); let alerts = monitor_transaction(&tx, &customer, &config, 1);
302
303 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); customer.allowed_destinations = 0b11; let tx = make_transaction(500_000, 7); 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); assert_eq!(calculate_velocity_severity(20, 10), AlertSeverity::High); assert_eq!(calculate_velocity_severity(15, 10), AlertSeverity::Medium); }
329}