github-actions
deploy: clean backend production release
7856e60
Raw
History Blame Contribute Delete
12.1 kB
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))
}
}
#[cfg(test)]
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,
}
}
#[test]
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());
}
#[test]
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);
}
#[test]
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
}
#[test]
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());
}
#[test]
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());
}
#[test]
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());
}
}