using OptimAI.BRE.RuleEngine.Domain; using OptimAI.BRE.Shared.Domain; namespace OptimAI.BRE.RuleEngine.Application; /// /// Multi-dimensional risk scoring engine. /// Computes weighted risk score from 0–100 across bureau, income, FI, fraud, and vehicle dimensions. /// public sealed class RiskScoringEngine : IRiskScoringEngine { private readonly IRiskWeightRepository _weightRepository; public RiskScoringEngine(IRiskWeightRepository weightRepository) { _weightRepository = weightRepository; } public async Task CalculateAsync(ExecutionContext context, List results) { var weights = await _weightRepository.GetWeightsAsync(context.TenantId, context.ProductCode); var data = context.Data; var componentScores = new Dictionary(); // ---- 1. Bureau Score Component (0–100, inverted for risk) componentScores["bureau"] = CalculateBureauScore(data, weights); // ---- 2. Income / FOIR Component componentScores["income"] = CalculateIncomeScore(data, weights); // ---- 3. FI Component componentScores["fi"] = CalculateFiScore(data, weights); // ---- 4. Fraud Component componentScores["fraud"] = CalculateFraudScore(data, weights); // ---- 5. Vehicle Component (for vehicle loans) componentScores["vehicle"] = CalculateVehicleScore(data, weights); // ---- 6. Employment Component componentScores["employment"] = CalculateEmploymentScore(data, weights); // ---- 7. Rule Match Penalty componentScores["rule_penalty"] = CalculateRulePenalty(results); // Weighted aggregate decimal totalWeight = weights.Sum(w => w.Value); decimal weightedScore = componentScores.Sum(cs => { var weight = weights.GetValueOrDefault(cs.Key, GetDefaultWeight(cs.Key)); return cs.Value * weight; }); decimal finalScore = totalWeight > 0 ? Math.Round(weightedScore / totalWeight, 2) : Math.Round(componentScores.Values.Average(), 2); finalScore = Math.Clamp(finalScore, 0, 100); return new RiskScoreResult { Score = finalScore, Category = ClassifyRisk(finalScore), TrafficLight = ScoreToTrafficLight(finalScore), ComponentScores = componentScores }; } private static decimal CalculateBureauScore(DynamicDataContext data, Dictionary weights) { var cibilScore = GetDecimal(data, "bureau.cibil_score"); var maxDpd = GetDecimal(data, "bureau.max_dpd_24m"); var writtenOff = GetBool(data, "bureau.written_off_amount"); var suitFiled = GetBool(data, "bureau.suit_filed"); var wilfulDefaulter = GetBool(data, "bureau.wilful_defaulter"); decimal score = 50m; // default medium if (cibilScore.HasValue) { score = cibilScore.Value switch { >= 780 => 5, >= 750 => 15, >= 720 => 25, >= 700 => 35, >= 680 => 45, >= 650 => 60, >= 620 => 70, >= 600 => 80, _ => 90 }; } // DPD penalty if (maxDpd.HasValue) { score += maxDpd.Value switch { 0 => 0, <= 30 => 10, <= 60 => 20, <= 90 => 30, _ => 40 }; } // Critical flags if (writtenOff) score = Math.Max(score, 85); if (suitFiled) score = Math.Max(score, 90); if (wilfulDefaulter) score = 100; return Math.Clamp(score, 0, 100); } private static decimal CalculateIncomeScore(DynamicDataContext data, Dictionary weights) { var foir = GetDecimal(data, "ratios.foir"); var income = GetDecimal(data, "employment.monthly_income"); var emiObligation = GetDecimal(data, "bureau.total_emi_obligation"); decimal score = 30m; if (foir.HasValue) { score = foir.Value switch { <= 30 => 10, <= 40 => 20, <= 50 => 35, <= 60 => 55, <= 70 => 70, <= 80 => 85, _ => 95 }; } else if (income.HasValue && emiObligation.HasValue && income.Value > 0) { var computedFoir = (emiObligation.Value / income.Value) * 100; score = computedFoir <= 40 ? 15 : computedFoir <= 60 ? 45 : 75; } return Math.Clamp(score, 0, 100); } private static decimal CalculateFiScore(DynamicDataContext data, Dictionary weights) { var fiNegative = GetBool(data, "fi.negative"); var addressMatch = GetBool(data, "fi.address_match"); var mobileMatch = GetBool(data, "fi.mobile_match"); var fiVerified = GetBool(data, "fi.verified"); if (!fiVerified) return 40m; // unverified decimal score = 10m; if (fiNegative) score += 60; if (!addressMatch) score += 20; if (!mobileMatch) score += 10; return Math.Clamp(score, 0, 100); } private static decimal CalculateFraudScore(DynamicDataContext data, Dictionary weights) { var fraudScore = GetDecimal(data, "fraud.score"); var blacklisted = GetBool(data, "fraud.blacklisted"); if (blacklisted) return 100; return fraudScore.HasValue ? Math.Clamp(fraudScore.Value, 0, 100) : 20m; } private static decimal CalculateVehicleScore(DynamicDataContext data, Dictionary weights) { var vehicleAge = GetDecimal(data, "vehicle.age_years"); var ltv = GetDecimal(data, "ratios.ltv"); decimal score = 20m; if (vehicleAge.HasValue) { score += vehicleAge.Value switch { <= 2 => 0, <= 5 => 10, <= 8 => 20, <= 10 => 35, _ => 50 }; } if (ltv.HasValue) { score += ltv.Value switch { <= 70 => 0, <= 80 => 10, <= 90 => 20, _ => 35 }; } return Math.Clamp(score, 0, 100); } private static decimal CalculateEmploymentScore(DynamicDataContext data, Dictionary weights) { var vintage = GetDecimal(data, "employment.vintage_months"); var empType = data.GetValue("employment.type")?.ToString(); decimal score = 30m; if (vintage.HasValue) { score = vintage.Value switch { >= 60 => 5, >= 36 => 15, >= 24 => 25, >= 12 => 40, >= 6 => 60, _ => 75 }; } // Salaried lower risk than self-employed if (empType?.ToLower() == "salaried") score = Math.Max(score - 10, 0); return Math.Clamp(score, 0, 100); } private static decimal CalculateRulePenalty(List results) { int matchedCount = results.Count(r => r.IsMatched); int totalCount = results.Count; if (totalCount == 0) return 0; return Math.Round((decimal)matchedCount / totalCount * 100, 2); } private static RiskCategory ClassifyRisk(decimal score) => score switch { <= 25 => RiskCategory.Low, <= 50 => RiskCategory.Medium, <= 75 => RiskCategory.High, _ => RiskCategory.Critical }; private static TrafficLight ScoreToTrafficLight(decimal score) => score switch { <= 40 => TrafficLight.Green, <= 65 => TrafficLight.Amber, _ => TrafficLight.Red }; private static decimal? GetDecimal(DynamicDataContext data, string path) { var val = data.GetValue(path); if (val == null) return null; if (decimal.TryParse(val.ToString(), out var d)) return d; return null; } private static bool GetBool(DynamicDataContext data, string path) { var val = data.GetValue(path); return val is true or "true" or 1; } private static decimal GetDefaultWeight(string component) => component switch { "bureau" => 30, "income" => 25, "fi" => 15, "fraud" => 15, "vehicle" => 10, "employment" => 10, "rule_penalty" => 5, _ => 10 }; } public interface IRiskWeightRepository { Task> GetWeightsAsync(Guid tenantId, string? productCode = null); }