omgy commited on
Commit
0bcd6fa
·
verified ·
1 Parent(s): f884b40

Update app/services/underwriting_service.py

Browse files
Files changed (1) hide show
  1. app/services/underwriting_service.py +250 -396
app/services/underwriting_service.py CHANGED
@@ -1,396 +1,250 @@
1
- """
2
- Underwriting service for loan decision logic.
3
- Implements credit evaluation and loan approval rules.
4
- """
5
-
6
- import math
7
- from datetime import datetime
8
- from typing import Any, Dict
9
-
10
- from app.config import settings
11
- from app.utils.logger import default_logger as logger
12
- from app.utils.logger import log_underwriting_decision
13
-
14
-
15
- class UnderwritingService:
16
- """Service for evaluating loan applications and making credit decisions."""
17
-
18
- def __init__(self):
19
- """Initialize underwriting service with configuration."""
20
- self.interest_rate = settings.DEFAULT_INTEREST_RATE
21
- self.min_loan_amount = settings.MIN_LOAN_AMOUNT
22
- self.max_loan_amount = settings.MAX_LOAN_AMOUNT
23
- self.min_tenure = settings.MIN_TENURE_MONTHS
24
- self.max_tenure = settings.MAX_TENURE_MONTHS
25
- self.excellent_credit_score = settings.EXCELLENT_CREDIT_SCORE
26
- self.good_credit_score = settings.GOOD_CREDIT_SCORE
27
- self.foir_threshold_a = settings.FOIR_THRESHOLD_A
28
- self.foir_threshold_b = settings.FOIR_THRESHOLD_B
29
-
30
- def evaluate_application(
31
- self,
32
- user_profile: Dict[str, Any],
33
- requested_amount: float,
34
- requested_tenure_months: int,
35
- ) -> Dict[str, Any]:
36
- """
37
- Evaluate a loan application and make a credit decision.
38
-
39
- Args:
40
- user_profile: User profile with income and credit info
41
- requested_amount: Requested loan amount
42
- requested_tenure_months: Requested tenure in months
43
-
44
- Returns:
45
- Decision dict with approval status, amount, EMI, etc.
46
- """
47
- logger.info(
48
- f"Evaluating loan: amount={requested_amount}, tenure={requested_tenure_months}"
49
- )
50
-
51
- # Extract user data
52
- monthly_income = user_profile.get("monthly_income", 0)
53
- existing_emi = user_profile.get("existing_emi", 0)
54
- credit_score = user_profile.get("mock_credit_score", 650)
55
- user_id = user_profile.get("user_id", "unknown")
56
-
57
- # Validate basic requirements
58
- validation_error = self._validate_loan_request(
59
- requested_amount, requested_tenure_months, monthly_income
60
- )
61
- if validation_error:
62
- return self._create_rejection_response(
63
- user_id,
64
- requested_amount,
65
- requested_tenure_months,
66
- credit_score,
67
- 0,
68
- validation_error,
69
- )
70
-
71
- # Calculate EMI
72
- emi = self._calculate_emi(requested_amount, requested_tenure_months)
73
-
74
- # Calculate FOIR (Fixed Obligations to Income Ratio)
75
- foir = self._calculate_foir(existing_emi, emi, monthly_income)
76
-
77
- # Make decision based on credit score and FOIR
78
- decision = self._make_decision(
79
- credit_score,
80
- foir,
81
- requested_amount,
82
- requested_tenure_months,
83
- monthly_income,
84
- existing_emi,
85
- )
86
-
87
- # Log decision
88
- log_underwriting_decision(
89
- logger,
90
- user_id,
91
- decision["decision"],
92
- decision["approved_amount"],
93
- credit_score,
94
- foir,
95
- )
96
-
97
- return decision
98
-
99
- def _validate_loan_request(
100
- self, amount: float, tenure: int, monthly_income: float
101
- ) -> str:
102
- """
103
- Validate basic loan request parameters.
104
-
105
- Returns:
106
- Error message if invalid, empty string if valid
107
- """
108
- if amount < self.min_loan_amount:
109
- return f"Loan amount must be at least ₹{self.min_loan_amount:,.0f}"
110
-
111
- if amount > self.max_loan_amount:
112
- return f"Loan amount cannot exceed ₹{self.max_loan_amount:,.0f}"
113
-
114
- if tenure < self.min_tenure:
115
- return f"Tenure must be at least {self.min_tenure} months"
116
-
117
- if tenure > self.max_tenure:
118
- return f"Tenure cannot exceed {self.max_tenure} months"
119
-
120
- if monthly_income <= 0:
121
- return "Valid monthly income is required"
122
-
123
- return ""
124
-
125
- def _calculate_emi(self, principal: float, tenure_months: int) -> float:
126
- """
127
- Calculate EMI using reducing balance method.
128
-
129
- Formula: EMI = P × r × (1 + r)^n / ((1 + r)^n - 1)
130
- Where:
131
- P = Principal loan amount
132
- r = Monthly interest rate (annual rate / 12 / 100)
133
- n = Tenure in months
134
-
135
- Args:
136
- principal: Loan amount
137
- tenure_months: Loan tenure in months
138
-
139
- Returns:
140
- Monthly EMI amount
141
- """
142
- # Convert annual interest rate to monthly rate
143
- monthly_rate = self.interest_rate / 12 / 100
144
-
145
- # Calculate EMI using the standard formula
146
- if monthly_rate == 0:
147
- # If interest rate is 0, EMI is simply principal / tenure
148
- emi = principal / tenure_months
149
- else:
150
- # Standard EMI calculation
151
- emi = (
152
- principal
153
- * monthly_rate
154
- * math.pow(1 + monthly_rate, tenure_months)
155
- / (math.pow(1 + monthly_rate, tenure_months) - 1)
156
- )
157
-
158
- return round(emi, 2)
159
-
160
- def _calculate_foir(
161
- self, existing_emi: float, new_emi: float, monthly_income: float
162
- ) -> float:
163
- """
164
- Calculate Fixed Obligations to Income Ratio.
165
-
166
- FOIR = (Existing EMI + New EMI) / Monthly Income
167
-
168
- Args:
169
- existing_emi: Existing loan EMIs
170
- new_emi: New loan EMI
171
- monthly_income: Monthly income
172
-
173
- Returns:
174
- FOIR ratio (0.0 to 1.0)
175
- """
176
- if monthly_income <= 0:
177
- return 1.0 # Maximum FOIR if income is invalid
178
-
179
- total_obligations = existing_emi + new_emi
180
- foir = total_obligations / monthly_income
181
-
182
- return round(foir, 3)
183
-
184
- def _make_decision(
185
- self,
186
- credit_score: int,
187
- foir: float,
188
- requested_amount: float,
189
- requested_tenure: int,
190
- monthly_income: float,
191
- existing_emi: float,
192
- ) -> Dict[str, Any]:
193
- """
194
- Make loan decision based on credit score and FOIR.
195
-
196
- Decision Rules:
197
- - Risk Band A (Excellent): Credit >= 720 AND FOIR <= 0.4
198
- APPROVED with full amount
199
- - Risk Band B (Good): Credit >= 680 AND FOIR <= 0.5
200
- → APPROVED with adjusted amount (80%) OR suggest lower amount
201
- - Risk Band C (Poor): Otherwise
202
- REJECTED
203
-
204
- Args:
205
- credit_score: User's credit score
206
- foir: Calculated FOIR
207
- requested_amount: Requested loan amount
208
- requested_tenure: Requested tenure
209
- monthly_income: Monthly income
210
- existing_emi: Existing EMIs
211
-
212
- Returns:
213
- Decision dictionary
214
- """
215
- # Risk Band A: Excellent - Full Approval
216
- if (
217
- credit_score >= self.excellent_credit_score
218
- and foir <= self.foir_threshold_a
219
- ):
220
- return self._create_approval_response(
221
- requested_amount,
222
- requested_tenure,
223
- credit_score,
224
- foir,
225
- "A",
226
- f"Approved! Excellent credit score ({credit_score}) and healthy FOIR ({foir:.1%}). "
227
- f"You qualify for the full amount with Risk Band A rating.",
228
- )
229
-
230
- # Risk Band B: Good - Conditional Approval or Adjustment
231
- if credit_score >= self.good_credit_score and foir <= self.foir_threshold_b:
232
- # If FOIR is slightly high, reduce the loan amount
233
- if foir > self.foir_threshold_a:
234
- # Calculate maximum affordable EMI
235
- max_affordable_emi = (
236
- monthly_income * self.foir_threshold_a
237
- ) - existing_emi
238
-
239
- # Calculate maximum loan amount based on affordable EMI
240
- monthly_rate = self.interest_rate / 12 / 100
241
- if monthly_rate > 0:
242
- adjusted_amount = (
243
- max_affordable_emi
244
- * (math.pow(1 + monthly_rate, requested_tenure) - 1)
245
- / (monthly_rate * math.pow(1 + monthly_rate, requested_tenure))
246
- )
247
- else:
248
- adjusted_amount = max_affordable_emi * requested_tenure
249
-
250
- adjusted_amount = round(adjusted_amount, 2)
251
-
252
- # Ensure adjusted amount is at least minimum
253
- if adjusted_amount < self.min_loan_amount:
254
- return self._create_rejection_response(
255
- "unknown",
256
- requested_amount,
257
- requested_tenure,
258
- credit_score,
259
- foir,
260
- f"Your current FOIR ({foir:.1%}) is too high. "
261
- f"Maximum affordable loan amount (₹{adjusted_amount:,.0f}) "
262
- f"is below minimum requirement.",
263
- )
264
-
265
- return self._create_adjustment_response(
266
- adjusted_amount,
267
- requested_tenure,
268
- credit_score,
269
- foir,
270
- "B",
271
- f"Approved with adjustment! Your credit score ({credit_score}) is good, "
272
- f"but your FOIR ({foir:.1%}) is slightly high. "
273
- f"We can approve ₹{adjusted_amount:,.0f} instead of ₹{requested_amount:,.0f} "
274
- f"to maintain healthy FOIR. Risk Band: B.",
275
- )
276
- else:
277
- # Full approval for Risk Band B
278
- return self._create_approval_response(
279
- requested_amount,
280
- requested_tenure,
281
- credit_score,
282
- foir,
283
- "B",
284
- f"Approved! Good credit score ({credit_score}) and acceptable FOIR ({foir:.1%}). "
285
- f"Risk Band B rating.",
286
- )
287
-
288
- # Risk Band C: Poor - Rejection
289
- reasons = []
290
- if credit_score < self.good_credit_score:
291
- reasons.append(
292
- f"credit score ({credit_score}) is below minimum requirement ({self.good_credit_score})"
293
- )
294
- if foir > self.foir_threshold_b:
295
- reasons.append(
296
- f"FOIR ({foir:.1%}) exceeds maximum threshold ({self.foir_threshold_b:.1%})"
297
- )
298
-
299
- explanation = (
300
- f"Unfortunately, we cannot approve this loan because "
301
- f"{' and '.join(reasons)}. "
302
- f"Please improve your credit profile and try again."
303
- )
304
-
305
- return self._create_rejection_response(
306
- "unknown",
307
- requested_amount,
308
- requested_tenure,
309
- credit_score,
310
- foir,
311
- explanation,
312
- )
313
-
314
- def _create_approval_response(
315
- self,
316
- amount: float,
317
- tenure: int,
318
- credit_score: int,
319
- foir: float,
320
- risk_band: str,
321
- explanation: str,
322
- ) -> Dict[str, Any]:
323
- """Create an approval decision response."""
324
- emi = self._calculate_emi(amount, tenure)
325
-
326
- return {
327
- "decision": "APPROVED",
328
- "approved_amount": amount,
329
- "tenure_months": tenure,
330
- "emi": emi,
331
- "interest_rate": self.interest_rate,
332
- "credit_score": credit_score,
333
- "foir": foir,
334
- "risk_band": risk_band,
335
- "explanation": explanation,
336
- "total_payable": round(emi * tenure, 2),
337
- "processing_fee": round(amount * 0.02, 2), # 2% processing fee
338
- }
339
-
340
- def _create_adjustment_response(
341
- self,
342
- adjusted_amount: float,
343
- tenure: int,
344
- credit_score: int,
345
- foir: float,
346
- risk_band: str,
347
- explanation: str,
348
- ) -> Dict[str, Any]:
349
- """Create an adjustment decision response."""
350
- emi = self._calculate_emi(adjusted_amount, tenure)
351
-
352
- # Recalculate FOIR with adjusted amount
353
- # Note: We don't have existing_emi here, so we use the provided foir
354
- # In practice, you'd recalculate with actual existing_emi
355
-
356
- return {
357
- "decision": "ADJUST",
358
- "approved_amount": adjusted_amount,
359
- "tenure_months": tenure,
360
- "emi": emi,
361
- "interest_rate": self.interest_rate,
362
- "credit_score": credit_score,
363
- "foir": foir,
364
- "risk_band": risk_band,
365
- "explanation": explanation,
366
- "total_payable": round(emi * tenure, 2),
367
- "processing_fee": round(adjusted_amount * 0.02, 2),
368
- }
369
-
370
- def _create_rejection_response(
371
- self,
372
- user_id: str,
373
- requested_amount: float,
374
- tenure: int,
375
- credit_score: int,
376
- foir: float,
377
- explanation: str,
378
- ) -> Dict[str, Any]:
379
- """Create a rejection decision response."""
380
- return {
381
- "decision": "REJECTED",
382
- "approved_amount": 0.0,
383
- "tenure_months": tenure,
384
- "emi": 0.0,
385
- "interest_rate": self.interest_rate,
386
- "credit_score": credit_score,
387
- "foir": foir,
388
- "risk_band": "C",
389
- "explanation": explanation,
390
- "total_payable": 0.0,
391
- "processing_fee": 0.0,
392
- }
393
-
394
-
395
- # Singleton instance
396
- underwriting_service = UnderwritingService()
 
1
+ """
2
+ Improved Underwriting Service with better adjustment logic.
3
+ Makes decisions more like a real loan officer would.
4
+ """
5
+
6
+ from typing import Dict
7
+ from app.config import settings
8
+ from app.utils.logger import setup_logger
9
+
10
+ logger = setup_logger("underwriting")
11
+
12
+
13
+ class UnderwritingService:
14
+ """Enhanced underwriting service for loan evaluation."""
15
+
16
+ def __init__(self):
17
+ """Initialize underwriting service with configuration."""
18
+ self.excellent_credit_score = settings.EXCELLENT_CREDIT_SCORE
19
+ self.good_credit_score = settings.GOOD_CREDIT_SCORE
20
+ self.foir_threshold_a = settings.FOIR_THRESHOLD_A
21
+ self.foir_threshold_b = settings.FOIR_THRESHOLD_B
22
+ self.default_interest_rate = settings.DEFAULT_INTEREST_RATE
23
+
24
+ def calculate_emi(self, principal: float, annual_rate: float, tenure_months: int) -> float:
25
+ """
26
+ Calculate EMI using standard formula.
27
+ EMI = P × r × (1 + r)^n / ((1 + r)^n - 1)
28
+ """
29
+ if tenure_months == 0:
30
+ return 0
31
+
32
+ monthly_rate = annual_rate / (12 * 100)
33
+ if monthly_rate == 0:
34
+ return principal / tenure_months
35
+
36
+ emi = (
37
+ principal
38
+ * monthly_rate
39
+ * (1 + monthly_rate) ** tenure_months
40
+ / ((1 + monthly_rate) ** tenure_months - 1)
41
+ )
42
+ return round(emi, 2)
43
+
44
+ def calculate_max_affordable_loan(
45
+ self,
46
+ monthly_income: float,
47
+ existing_emi: float,
48
+ credit_score: int,
49
+ tenure_months: int,
50
+ interest_rate: float
51
+ ) -> float:
52
+ """
53
+ Calculate maximum loan amount the user can afford.
54
+ """
55
+ # Determine FOIR threshold based on credit score
56
+ if credit_score >= self.excellent_credit_score:
57
+ max_foir = self.foir_threshold_a # 40%
58
+ else:
59
+ max_foir = self.foir_threshold_b # 50%
60
+
61
+ # Calculate maximum affordable EMI
62
+ max_total_emi = monthly_income * max_foir
63
+ max_new_emi = max_total_emi - existing_emi
64
+
65
+ if max_new_emi <= 0:
66
+ return 0
67
+
68
+ # Reverse calculate loan amount from EMI
69
+ monthly_rate = interest_rate / (12 * 100)
70
+ if monthly_rate == 0:
71
+ return max_new_emi * tenure_months
72
+
73
+ max_loan = (
74
+ max_new_emi
75
+ * ((1 + monthly_rate) ** tenure_months - 1)
76
+ / (monthly_rate * (1 + monthly_rate) ** tenure_months)
77
+ )
78
+
79
+ return round(max_loan, 2)
80
+
81
+ def determine_risk_band(self, credit_score: int, foir: float) -> str:
82
+ """Determine risk classification based on credit score and FOIR."""
83
+ if credit_score >= self.excellent_credit_score and foir <= self.foir_threshold_a:
84
+ return "A" # Low risk
85
+ elif credit_score >= self.good_credit_score and foir <= self.foir_threshold_b:
86
+ return "B" # Medium risk
87
+ else:
88
+ return "C" # High risk
89
+
90
+ def evaluate_loan(
91
+ self,
92
+ monthly_income: float,
93
+ existing_emi: float,
94
+ credit_score: int,
95
+ requested_amount: float,
96
+ requested_tenure_months: int,
97
+ ) -> Dict:
98
+ """
99
+ Evaluate loan application with smart adjustment logic.
100
+
101
+ Returns decision with APPROVED, REJECTED, or ADJUST status.
102
+ """
103
+ try:
104
+ logger.info(f"Evaluating loan: amount={requested_amount}, tenure={requested_tenure_months}")
105
+
106
+ # Determine interest rate based on credit score
107
+ if credit_score >= self.excellent_credit_score:
108
+ interest_rate = self.default_interest_rate - 1 # 11%
109
+ elif credit_score >= self.good_credit_score:
110
+ interest_rate = self.default_interest_rate # 12%
111
+ else:
112
+ interest_rate = self.default_interest_rate + 1 # 13%
113
+
114
+ # Calculate EMI for requested amount
115
+ emi = self.calculate_emi(requested_amount, interest_rate, requested_tenure_months)
116
+
117
+ # Calculate FOIR
118
+ total_emi = existing_emi + emi
119
+ foir = total_emi / monthly_income if monthly_income > 0 else 1.0
120
+
121
+ # Determine risk band
122
+ risk_band = self.determine_risk_band(credit_score, foir)
123
+
124
+ # Decision logic
125
+ if credit_score >= self.excellent_credit_score:
126
+ max_foir = self.foir_threshold_a
127
+ else:
128
+ max_foir = self.foir_threshold_b
129
+
130
+ # CASE 1: Approve directly if within limits
131
+ if foir <= max_foir:
132
+ return {
133
+ "decision": "APPROVED",
134
+ "approved_amount": requested_amount,
135
+ "tenure_months": requested_tenure_months,
136
+ "emi": emi,
137
+ "interest_rate": interest_rate,
138
+ "credit_score": credit_score,
139
+ "foir": round(foir, 3),
140
+ "risk_band": risk_band,
141
+ "explanation": (
142
+ f"Congratulations! Your loan of ₹{requested_amount:,.0f} for {requested_tenure_months} months "
143
+ f"has been approved. Your monthly EMI will be ₹{emi:,.2f} at {interest_rate}% p.a. "
144
+ f"Your FOIR is {foir*100:.1f}%, which is within acceptable limits."
145
+ ),
146
+ "total_payable": round(emi * requested_tenure_months, 2),
147
+ "processing_fee": round(requested_amount * 0.02, 2)
148
+ }
149
+
150
+ # CASE 2: Offer adjustment if possible
151
+ max_affordable = self.calculate_max_affordable_loan(
152
+ monthly_income,
153
+ existing_emi,
154
+ credit_score,
155
+ requested_tenure_months,
156
+ interest_rate
157
+ )
158
+
159
+ # If we can approve at least 60% of requested amount, offer adjustment
160
+ if max_affordable >= requested_amount * 0.6:
161
+ adjusted_amount = max_affordable
162
+ adjusted_emi = self.calculate_emi(adjusted_amount, interest_rate, requested_tenure_months)
163
+ adjusted_foir = (existing_emi + adjusted_emi) / monthly_income
164
+ adjusted_risk_band = self.determine_risk_band(credit_score, adjusted_foir)
165
+
166
+ return {
167
+ "decision": "ADJUST",
168
+ "approved_amount": adjusted_amount,
169
+ "tenure_months": requested_tenure_months,
170
+ "emi": adjusted_emi,
171
+ "interest_rate": interest_rate,
172
+ "credit_score": credit_score,
173
+ "foir": round(adjusted_foir, 3),
174
+ "risk_band": adjusted_risk_band,
175
+ "explanation": (
176
+ f"While we cannot approve the full ₹{requested_amount:,.0f}, we have good news! "
177
+ f"We can approve ₹{adjusted_amount:,.0f} for {requested_tenure_months} months "
178
+ f"with a monthly EMI of ₹{adjusted_emi:,.2f} at {interest_rate}% p.a. "
179
+ f"This keeps your FOIR at a healthy {adjusted_foir*100:.1f}%. "
180
+ f"Would this adjusted amount work for you?"
181
+ ),
182
+ "total_payable": round(adjusted_emi * requested_tenure_months, 2),
183
+ "processing_fee": round(adjusted_amount * 0.02, 2)
184
+ }
185
+
186
+ # CASE 3: Try longer tenure
187
+ longer_tenure = min(requested_tenure_months + 12, settings.MAX_TENURE_MONTHS)
188
+ max_affordable_longer = self.calculate_max_affordable_loan(
189
+ monthly_income,
190
+ existing_emi,
191
+ credit_score,
192
+ longer_tenure,
193
+ interest_rate
194
+ )
195
+
196
+ if max_affordable_longer >= requested_amount * 0.8:
197
+ adjusted_emi = self.calculate_emi(requested_amount, interest_rate, longer_tenure)
198
+ adjusted_foir = (existing_emi + adjusted_emi) / monthly_income
199
+ adjusted_risk_band = self.determine_risk_band(credit_score, adjusted_foir)
200
+
201
+ return {
202
+ "decision": "ADJUST",
203
+ "approved_amount": requested_amount,
204
+ "tenure_months": longer_tenure,
205
+ "emi": adjusted_emi,
206
+ "interest_rate": interest_rate,
207
+ "credit_score": credit_score,
208
+ "foir": round(adjusted_foir, 3),
209
+ "risk_band": adjusted_risk_band,
210
+ "explanation": (
211
+ f"We can approve your requested amount of ₹{requested_amount:,.0f}, "
212
+ f"but we recommend extending the tenure to {longer_tenure} months. "
213
+ f"This reduces your monthly EMI to ₹{adjusted_emi:,.2f} at {interest_rate}% p.a., "
214
+ f"keeping your FOIR at {adjusted_foir*100:.1f}%. Would you like to proceed with this?"
215
+ ),
216
+ "total_payable": round(adjusted_emi * longer_tenure, 2),
217
+ "processing_fee": round(requested_amount * 0.02, 2)
218
+ }
219
+
220
+ # CASE 4: Reject with helpful feedback
221
+ return {
222
+ "decision": "REJECTED",
223
+ "approved_amount": 0.0,
224
+ "tenure_months": requested_tenure_months,
225
+ "emi": 0.0,
226
+ "interest_rate": interest_rate,
227
+ "credit_score": credit_score,
228
+ "foir": round(foir, 3),
229
+ "risk_band": "C",
230
+ "explanation": (
231
+ f"Unfortunately, we cannot approve a loan of ₹{requested_amount:,.0f} at this time. "
232
+ f"Your current FOIR is {foir*100:.1f}%, which exceeds our maximum threshold of {max_foir*100:.0f}%. "
233
+ f"To improve your chances:\n"
234
+ f"• Reduce existing EMIs (currently ₹{existing_emi:,.0f})\n"
235
+ f"• Increase your monthly income (currently ₹{monthly_income:,.0f})\n"
236
+ f"• Improve your credit score (currently {credit_score})\n"
237
+ f"• Request a smaller loan amount\n\n"
238
+ f"You can reapply after making these improvements."
239
+ ),
240
+ "total_payable": 0.0,
241
+ "processing_fee": 0.0
242
+ }
243
+
244
+ except Exception as e:
245
+ logger.error(f"Error in underwriting: {str(e)}", exc_info=True)
246
+ raise
247
+
248
+
249
+ # Global instance
250
+ underwriting_service = UnderwritingService()