Spaces:
Running
Running
| use crate::domain::models::OrderRecord; | |
| use chrono::Timelike; | |
| use serde_json::Value; | |
| /// Strategy trait defining a singular risk checking vector. | |
| /// | |
| /// Follows the Open-Closed Principle (OCP) of OOPS: adding new risk vectors | |
| /// is accomplished by implementing this trait without modifying existing ones. | |
| pub trait RiskEvaluationStrategy: Send + Sync { | |
| fn evaluate( | |
| &self, | |
| order: &OrderRecord, | |
| history_count: i64, | |
| pincode_volatility: f64, | |
| ) -> Option<(f64, &'static str, Value)>; | |
| } | |
| // ======================================================== | |
| // CONCRETE STRATEGIES (RISK VECTORS) | |
| // ======================================================== | |
| /// VECTOR 0: Network Intelligence (Volatility) | |
| /// Adjusts risk based on systemic logistics failures in the target pincode. | |
| pub struct LogisticsVolatilityStrategy; | |
| impl RiskEvaluationStrategy for LogisticsVolatilityStrategy { | |
| fn evaluate( | |
| &self, | |
| _order: &OrderRecord, | |
| _history_count: i64, | |
| pincode_volatility: f64, | |
| ) -> Option<(f64, &'static str, Value)> { | |
| if pincode_volatility > 0.3 { | |
| let penalty = (pincode_volatility * 50.0).min(40.0); | |
| Some(( | |
| penalty, | |
| "HIGH_LOGISTICS_VOLATILITY", | |
| serde_json::json!(penalty), | |
| )) | |
| } else { | |
| None | |
| } | |
| } | |
| } | |
| /// VECTOR 1: Account Longevity & Trust | |
| /// New accounts have no established behavioral baseline and carry a higher weight. | |
| pub struct AccountLongevityStrategy; | |
| impl RiskEvaluationStrategy for AccountLongevityStrategy { | |
| fn evaluate( | |
| &self, | |
| _order: &OrderRecord, | |
| history_count: i64, | |
| _pincode_volatility: f64, | |
| ) -> Option<(f64, &'static str, Value)> { | |
| if history_count == 0 { | |
| Some((30.0, "NEW_BUYER", serde_json::json!(30))) | |
| } else if history_count < 3 { | |
| Some((10.0, "EMERGING_BUYER", serde_json::json!(10))) | |
| } else { | |
| None | |
| } | |
| } | |
| } | |
| /// VECTOR 2: Volumetric Ticket Analysis | |
| /// High-value orders trigger higher smart scrutiny to protect merchant liquidity. | |
| pub struct VolumetricTicketValueStrategy; | |
| impl RiskEvaluationStrategy for VolumetricTicketValueStrategy { | |
| fn evaluate( | |
| &self, | |
| order: &OrderRecord, | |
| _history_count: i64, | |
| _pincode_volatility: f64, | |
| ) -> Option<(f64, &'static str, Value)> { | |
| if order.price_inr > 10000.0 { | |
| Some((20.0, "HIGH_TICKET_VALUE", serde_json::json!(20))) | |
| } else { | |
| None | |
| } | |
| } | |
| } | |
| /// VECTOR 3: Logistics & Distance | |
| /// Cross-country shipments or long-haul logistics paths increase transit risk. | |
| pub struct LogisticsDistanceStrategy; | |
| impl RiskEvaluationStrategy for LogisticsDistanceStrategy { | |
| fn evaluate( | |
| &self, | |
| order: &OrderRecord, | |
| _history_count: i64, | |
| _pincode_volatility: f64, | |
| ) -> Option<(f64, &'static str, Value)> { | |
| if order.distance_km > 1000.0 { | |
| Some((15.0, "LONG_DISTANCE_LOGISTICS", serde_json::json!(15))) | |
| } else { | |
| None | |
| } | |
| } | |
| } | |
| /// VECTOR 4: Correlation Checks | |
| /// Combining "New Buyer" with "High Value" creates a significant risk profile. | |
| pub struct CorrelationStrategy; | |
| impl RiskEvaluationStrategy for CorrelationStrategy { | |
| fn evaluate( | |
| &self, | |
| order: &OrderRecord, | |
| history_count: i64, | |
| _pincode_volatility: f64, | |
| ) -> Option<(f64, &'static str, Value)> { | |
| if order.price_inr > 5000.0 && history_count == 0 { | |
| Some((15.0, "HIGH_VALUE_NEW_ACCOUNT", serde_json::json!(15))) | |
| } else { | |
| None | |
| } | |
| } | |
| } | |
| /// VECTOR 5: PII Reputation (Domain Integrity) | |
| /// Detects suspicious or disposable email providers commonly used in automated fraud. | |
| pub struct PiiReputationStrategy; | |
| impl RiskEvaluationStrategy for PiiReputationStrategy { | |
| fn evaluate( | |
| &self, | |
| order: &OrderRecord, | |
| _history_count: i64, | |
| _pincode_volatility: f64, | |
| ) -> Option<(f64, &'static str, Value)> { | |
| let suspicious_domains = ["tempmail.com", "throwaway.com", "test.com"]; | |
| if suspicious_domains | |
| .iter() | |
| .any(|d| order.buyer_email.ends_with(d)) | |
| { | |
| Some((25.0, "SUSPICIOUS_EMAIL_DOMAIN", serde_json::json!(25))) | |
| } else { | |
| None | |
| } | |
| } | |
| } | |
| /// VECTOR 6: Temporal Anomalies | |
| /// High-value transactions during low-activity windows (late night) are penalized. | |
| pub struct TemporalAnomalyStrategy; | |
| impl RiskEvaluationStrategy for TemporalAnomalyStrategy { | |
| fn evaluate( | |
| &self, | |
| order: &OrderRecord, | |
| _history_count: i64, | |
| _pincode_volatility: f64, | |
| ) -> Option<(f64, &'static str, Value)> { | |
| if let Some(ts) = order.created_at { | |
| let hour = ts.time().hour(); | |
| if (hour >= 23 || hour <= 4) && order.price_inr > 2000.0 { | |
| Some((10.0, "LATE_NIGHT_HIGH_VALUE", serde_json::json!(10))) | |
| } else { | |
| None | |
| } | |
| } else { | |
| None | |
| } | |
| } | |
| } | |
| // ======================================================== | |
| // RISK ENGINE CLASS & FACTORY PIPELINE | |
| // ======================================================== | |
| /// The `RiskEngine` is the security check system of the Rtix platform. | |
| /// | |
| /// It performs automatic safety checks on every transaction to detect potential fraud | |
| /// and protect the merchant's revenue. Refactored using robust OOPS Strategy patterns. | |
| pub struct RiskEngine { | |
| strategies: Vec<Box<dyn RiskEvaluationStrategy>>, | |
| } | |
| impl Default for RiskEngine { | |
| fn default() -> Self { | |
| Self { | |
| strategies: vec![ | |
| Box::new(LogisticsVolatilityStrategy), | |
| Box::new(AccountLongevityStrategy), | |
| Box::new(VolumetricTicketValueStrategy), | |
| Box::new(LogisticsDistanceStrategy), | |
| Box::new(CorrelationStrategy), | |
| Box::new(PiiReputationStrategy), | |
| Box::new(TemporalAnomalyStrategy), | |
| ], | |
| } | |
| } | |
| } | |
| impl RiskEngine { | |
| /// Instantiates a new RiskEngine with default strategy vectors registered. | |
| pub fn new() -> Self { | |
| Self::default() | |
| } | |
| /// Dynamically register a new risk evaluation strategy vector. | |
| pub fn register_strategy(&mut self, strategy: Box<dyn RiskEvaluationStrategy>) { | |
| self.strategies.push(strategy); | |
| } | |
| /// Calculates a safety score (0-100) for a given order. | |
| /// | |
| /// Preserves static interface compatibility to ensure other parts of the application | |
| /// do not break. | |
| /// | |
| /// # Arguments | |
| /// * `order` - The order record containing price, distance, and buyer PII. | |
| /// * `history_count` - Number of successful previous orders by this buyer phone. | |
| /// * `pincode_volatility` - Real-time network volatility for the target delivery zone. | |
| /// | |
| /// # Returns | |
| /// A tuple containing the numeric risk score and a JSON object detailing the activated risk flags. | |
| pub fn calculate_risk_score( | |
| order: &OrderRecord, | |
| history_count: i64, | |
| pincode_volatility: f64, | |
| ) -> (f64, serde_json::Value) { | |
| let engine = Self::new(); | |
| engine.evaluate_risk(order, history_count, pincode_volatility) | |
| } | |
| /// Evaluates all registered strategies polmorphicly. | |
| pub fn evaluate_risk( | |
| &self, | |
| order: &OrderRecord, | |
| history_count: i64, | |
| pincode_volatility: f64, | |
| ) -> (f64, serde_json::Value) { | |
| let mut score = 0.0; | |
| let mut flags = serde_json::Map::new(); | |
| for strategy in &self.strategies { | |
| if let Some((points, flag_name, detail)) = | |
| strategy.evaluate(order, history_count, pincode_volatility) | |
| { | |
| score += points; | |
| flags.insert(flag_name.to_string(), detail); | |
| } | |
| } | |
| // Cap the final score to ensure it fits within the standard 0-100 range. | |
| let final_score = score.min(100.0); | |
| (final_score, serde_json::Value::Object(flags)) | |
| } | |
| } | |
| mod tests { | |
| use super::*; | |
| fn mock_order(price: f64, distance: f64) -> OrderRecord { | |
| OrderRecord { | |
| transaction_id: "txn_123".into(), | |
| merchant_id: "m_123".into(), | |
| link_id: "lnk_123".into(), | |
| buyer_phone: "9999999999".into(), | |
| buyer_phone_hash: None, | |
| buyer_name: "Test User".into(), | |
| buyer_email: "test@example.com".into(), | |
| shipping_pincode: Some("110001".into()), | |
| delivery_address: Some("123 Test Street, Bangalore".into()), | |
| price_inr: price, | |
| status: "PENDING".into(), | |
| vpa: None, | |
| payu_id: String::new(), | |
| outbound_weight: 500.0, | |
| return_weight: 0.0, | |
| proof_data: None, | |
| proof_received_at: None, | |
| settled_at: None, | |
| paid_at: None, | |
| shipped_at: None, | |
| delivered_at: None, | |
| shipping_method: None, | |
| estimated_delivery_at: None, | |
| is_payment: false, | |
| platform_fee_paid: false, | |
| platform_fee: 0.0, | |
| delivery_fee: 100.0, | |
| distance_km: distance, | |
| risk_score: 0.0, | |
| risk_flags: None, | |
| cgst: 0.0, | |
| sgst: 0.0, | |
| igst: 0.0, | |
| utr_number: None, | |
| delivery_gps_lat: None, | |
| delivery_gps_lng: None, | |
| is_geofence_verified: None, | |
| pincode_volatility_at_checkout: 0.0, | |
| coupon_code: None, | |
| discount_amount: 0.0, | |
| checkout_gps_lat: None, | |
| checkout_gps_lng: None, | |
| device_fingerprint: None, | |
| created_at: None, | |
| platform_fee_utr: None, | |
| brand_name: None, | |
| } | |
| } | |
| fn test_new_buyer_high_value_risk() { | |
| // New buyer (30), >10000 (20), >5000 & new (15) => 65.0 | |
| let order = mock_order(15000.0, 500.0); | |
| let (score, flags) = RiskEngine::calculate_risk_score(&order, 0, 0.0); | |
| assert_eq!(score, 65.0); | |
| assert!(flags.get("NEW_BUYER").is_some()); | |
| } | |
| fn test_trusted_buyer_low_value_risk() { | |
| // Returning buyer > 3 orders (0), low value (0), low distance (0) => 0.0 | |
| let order = mock_order(1000.0, 50.0); | |
| let (score, _) = RiskEngine::calculate_risk_score(&order, 5, 0.0); | |
| assert_eq!(score, 0.0); | |
| } | |
| fn test_maximum_risk_cap() { | |
| let mut order = mock_order(15000.0, 1500.0); | |
| order.price_inr = 999999.0; | |
| let (score, _) = RiskEngine::calculate_risk_score(&order, 0, 0.0); | |
| assert_eq!(score, 80.0); // 30 (new) + 20 (>10k) + 15 (>1k km) + 15 (high value new account) = 80 | |
| } | |
| fn test_suspicious_email_risk() { | |
| let mut order = mock_order(1000.0, 50.0); | |
| order.buyer_email = "scammer@tempmail.com".into(); | |
| let (score, flags) = RiskEngine::calculate_risk_score(&order, 5, 0.0); | |
| assert_eq!(score, 25.0); | |
| assert!(flags.get("SUSPICIOUS_EMAIL_DOMAIN").is_some()); | |
| } | |
| fn test_high_volatility_risk() { | |
| let order = mock_order(1000.0, 50.0); | |
| let (score, flags) = RiskEngine::calculate_risk_score(&order, 5, 0.5); // 50% volatility | |
| assert_eq!(score, 25.0); // 0.5 * 50 = 25 | |
| assert!(flags.get("HIGH_LOGISTICS_VOLATILITY").is_some()); | |
| } | |
| fn test_late_night_risk() { | |
| let mut order = mock_order(5000.0, 50.0); | |
| // Late night: 1 AM | |
| use chrono::NaiveDate; | |
| order.created_at = Some( | |
| NaiveDate::from_ymd_opt(2026, 5, 6) | |
| .unwrap() | |
| .and_hms_opt(1, 0, 0) | |
| .unwrap(), | |
| ); | |
| let (score, flags) = RiskEngine::calculate_risk_score(&order, 5, 0.0); | |
| assert_eq!(score, 10.0); | |
| assert!(flags.get("LATE_NIGHT_HIGH_VALUE").is_some()); | |
| } | |
| } | |