dscf-credit 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/concerns/dscf/core/copilot-instructions.md +683 -0
- data/app/controllers/concerns/dscf/core/reviewable_controller.rb +347 -0
- data/app/controllers/dscf/credit/categories_controller.rb +3 -3
- data/app/controllers/dscf/credit/credit_lines_controller.rb +21 -13
- data/app/controllers/dscf/credit/eligible_credit_lines_controller.rb +50 -8
- data/app/controllers/dscf/credit/facilitator_applications_controller.rb +35 -0
- data/app/controllers/dscf/credit/facilitators_controller.rb +8 -96
- data/app/controllers/dscf/credit/loan_applications_controller.rb +252 -0
- data/app/controllers/dscf/credit/loan_profiles_controller.rb +61 -68
- data/app/controllers/dscf/credit/payment_requests_controller.rb +3 -5
- data/app/controllers/dscf/credit/scoring_parameters_controller.rb +59 -13
- data/app/controllers/dscf/credit/system_configs_controller.rb +30 -12
- data/app/models/concerns/core/reviewable_model.rb +31 -0
- data/app/models/dscf/credit/bank.rb +3 -3
- data/app/models/dscf/credit/bank_branch.rb +1 -1
- data/app/models/dscf/credit/category.rb +1 -2
- data/app/models/dscf/credit/credit_line.rb +4 -10
- data/app/models/dscf/credit/eligible_credit_line.rb +2 -2
- data/app/models/dscf/credit/facilitator.rb +6 -17
- data/app/models/dscf/credit/facilitator_application.rb +20 -0
- data/app/models/dscf/credit/loan_application.rb +30 -0
- data/app/models/dscf/credit/loan_profile.rb +10 -30
- data/app/models/dscf/credit/parameter_normalizer.rb +1 -1
- data/app/models/dscf/credit/scoring_parameter.rb +5 -7
- data/app/models/dscf/credit/system_config.rb +4 -9
- data/app/serializers/dscf/credit/category_serializer.rb +0 -1
- data/app/serializers/dscf/credit/credit_line_serializer.rb +2 -2
- data/app/serializers/dscf/credit/facilitator_application_serializer.rb +7 -0
- data/app/serializers/dscf/credit/facilitator_serializer.rb +3 -6
- data/app/serializers/dscf/credit/loan_application_serializer.rb +12 -0
- data/app/serializers/dscf/credit/loan_profile_serializer.rb +3 -6
- data/app/serializers/dscf/credit/scoring_parameter_serializer.rb +3 -4
- data/app/serializers/dscf/credit/system_config_serializer.rb +2 -2
- data/app/services/dscf/credit/credit_scoring_engine.rb +258 -0
- data/app/services/dscf/credit/facility_limit_calculation_engine.rb +159 -0
- data/app/services/dscf/credit/loan_profile_creation_service.rb +91 -0
- data/app/services/dscf/credit/risk_application_service.rb +61 -11
- data/config/locales/en.yml +63 -48
- data/config/routes.rb +31 -17
- data/db/migrate/20250822091131_create_dscf_credit_credit_lines.rb +1 -8
- data/db/migrate/20250822091820_create_dscf_credit_system_configs.rb +0 -7
- data/db/migrate/20250822092050_create_dscf_credit_scoring_parameters.rb +2 -6
- data/db/migrate/20250822092225_create_dscf_credit_parameter_normalizers.rb +1 -1
- data/db/migrate/20250822092236_create_dscf_credit_loan_applications.rb +20 -0
- data/db/migrate/20250822092246_create_dscf_credit_loan_profiles.rb +7 -19
- data/db/migrate/20250822092426_create_dscf_credit_facilitator_applications.rb +10 -0
- data/db/migrate/20250822092436_create_dscf_credit_facilitators.rb +1 -16
- data/db/seeds.rb +316 -203
- data/lib/dscf/credit/version.rb +1 -1
- data/spec/factories/dscf/credit/banks.rb +1 -1
- data/spec/factories/dscf/credit/credit_lines.rb +0 -23
- data/spec/factories/dscf/credit/facilitator_applications.rb +37 -0
- data/spec/factories/dscf/credit/facilitators.rb +8 -30
- data/spec/factories/dscf/credit/loan_applications.rb +42 -0
- data/spec/factories/dscf/credit/loan_profiles.rb +20 -34
- data/spec/factories/dscf/credit/parameter_normalizers.rb +4 -4
- data/spec/factories/dscf/credit/scoring_parameters.rb +14 -11
- data/spec/factories/dscf/credit/system_configs.rb +21 -5
- metadata +20 -10
- data/app/controllers/concerns/dscf/credit/reviewable.rb +0 -112
- data/app/controllers/dscf/credit/scoring_tables_controller.rb +0 -63
- data/app/models/dscf/credit/scoring_table.rb +0 -24
- data/app/serializers/dscf/credit/scoring_table_serializer.rb +0 -9
- data/db/migrate/20250901172842_create_dscf_credit_scoring_tables.rb +0 -18
- data/spec/factories/dscf/credit/scoring_tables.rb +0 -25
@@ -0,0 +1,258 @@
|
|
1
|
+
module Dscf::Credit
|
2
|
+
class CreditScoringEngine
|
3
|
+
attr_reader :loan_application, :category_id, :errors
|
4
|
+
|
5
|
+
def initialize(loan_application_id, category_id)
|
6
|
+
@loan_application = find_loan_application(loan_application_id)
|
7
|
+
@category_id = category_id
|
8
|
+
@errors = []
|
9
|
+
end
|
10
|
+
|
11
|
+
def calculate_score
|
12
|
+
return error_result("Loan application not found") unless loan_application
|
13
|
+
return error_result("Category ID is required") unless category_id
|
14
|
+
|
15
|
+
begin
|
16
|
+
scoring_parameters = get_active_scoring_parameters
|
17
|
+
|
18
|
+
if scoring_parameters.empty?
|
19
|
+
return error_result("No active scoring parameters found for this bank for category ID #{category_id}")
|
20
|
+
end
|
21
|
+
|
22
|
+
# Calculate weighted score using the formula
|
23
|
+
score_result = calculate_weighted_score(scoring_parameters)
|
24
|
+
|
25
|
+
success_result(score_result)
|
26
|
+
rescue StandardError => e
|
27
|
+
Rails.logger.error "Credit scoring failed: #{e.message}"
|
28
|
+
Rails.logger.error e.backtrace.join("\n")
|
29
|
+
error_result("Score calculation failed: #{e.message}")
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def find_loan_application(loan_application_id)
|
36
|
+
Dscf::Credit::LoanApplication.includes(
|
37
|
+
:bank,
|
38
|
+
bank: :scoring_parameters
|
39
|
+
).find_by(id: loan_application_id)
|
40
|
+
end
|
41
|
+
|
42
|
+
def get_active_scoring_parameters
|
43
|
+
return [] unless loan_application&.bank
|
44
|
+
|
45
|
+
loan_application.bank
|
46
|
+
.scoring_parameters
|
47
|
+
.active
|
48
|
+
.where(category_id: category_id)
|
49
|
+
.includes(:scoring_param_type, :parameter_normalizers, :category)
|
50
|
+
end
|
51
|
+
|
52
|
+
def calculate_weighted_score(scoring_parameters)
|
53
|
+
total_weighted_score = 0.0
|
54
|
+
total_weight = 0.0
|
55
|
+
score_breakdown = []
|
56
|
+
parameters_processed = 0
|
57
|
+
parameters_skipped = 0
|
58
|
+
|
59
|
+
scoring_parameters.each do |parameter|
|
60
|
+
raw_value = extract_parameter_value(parameter)
|
61
|
+
|
62
|
+
if raw_value.nil?
|
63
|
+
parameters_skipped += 1
|
64
|
+
score_breakdown << {
|
65
|
+
parameter_id: parameter.id,
|
66
|
+
parameter_name: parameter.name,
|
67
|
+
parameter_type: parameter.scoring_param_type.name,
|
68
|
+
raw_value: nil,
|
69
|
+
normalized_value: nil,
|
70
|
+
weight: parameter.weight,
|
71
|
+
weighted_score: 0.0,
|
72
|
+
status: "missing"
|
73
|
+
}
|
74
|
+
next
|
75
|
+
end
|
76
|
+
|
77
|
+
normalized_value = normalize_parameter_value(parameter, raw_value)
|
78
|
+
weighted_score = normalized_value * parameter.weight
|
79
|
+
|
80
|
+
total_weighted_score += weighted_score
|
81
|
+
total_weight += parameter.weight
|
82
|
+
parameters_processed += 1
|
83
|
+
|
84
|
+
score_breakdown << {
|
85
|
+
parameter_id: parameter.id,
|
86
|
+
parameter_name: parameter.name,
|
87
|
+
parameter_type: parameter.scoring_param_type.name,
|
88
|
+
raw_value: raw_value,
|
89
|
+
normalized_value: normalized_value,
|
90
|
+
weight: parameter.weight,
|
91
|
+
weighted_score: weighted_score,
|
92
|
+
status: "processed"
|
93
|
+
}
|
94
|
+
end
|
95
|
+
|
96
|
+
# Calculate final score using the formula: (sum of normalized_value × weight) / total_weight × 100
|
97
|
+
final_score = total_weight > 0 ? (total_weighted_score / total_weight * 100) : 0.0
|
98
|
+
|
99
|
+
{
|
100
|
+
score: final_score.round(2),
|
101
|
+
total_weighted_score: total_weighted_score.round(4),
|
102
|
+
total_weight: total_weight.round(4),
|
103
|
+
parameters_processed: parameters_processed,
|
104
|
+
parameters_skipped: parameters_skipped,
|
105
|
+
breakdown: score_breakdown
|
106
|
+
}
|
107
|
+
end
|
108
|
+
|
109
|
+
def extract_parameter_value(parameter)
|
110
|
+
param_type_name = parameter.scoring_param_type.name
|
111
|
+
parameter_id = parameter.id.to_s
|
112
|
+
|
113
|
+
# Get the appropriate JSON field based on parameter type
|
114
|
+
json_field = get_json_field_for_param_type(param_type_name)
|
115
|
+
return nil unless json_field
|
116
|
+
|
117
|
+
# Extract value using parameter ID as key
|
118
|
+
param_data = json_field[parameter_id]
|
119
|
+
return nil unless param_data.is_a?(Hash)
|
120
|
+
|
121
|
+
# Validate that the param_name matches (optional safety check)
|
122
|
+
expected_param_name = param_data["param_name"]
|
123
|
+
if expected_param_name && expected_param_name != parameter.name
|
124
|
+
Rails.logger.warn "Parameter name mismatch for ID #{parameter_id}: expected '#{parameter.name}', got '#{expected_param_name}'"
|
125
|
+
end
|
126
|
+
|
127
|
+
param_data["value"]
|
128
|
+
end
|
129
|
+
|
130
|
+
def get_json_field_for_param_type(param_type_name)
|
131
|
+
case param_type_name.downcase
|
132
|
+
when "user_info"
|
133
|
+
loan_application.user_info || {}
|
134
|
+
when "bank_info"
|
135
|
+
loan_application.bank_info || {}
|
136
|
+
when "facilitator_info"
|
137
|
+
loan_application.facilitator_info || {}
|
138
|
+
when "field_assessment"
|
139
|
+
loan_application.field_assessment || {}
|
140
|
+
else
|
141
|
+
Rails.logger.warn "Unknown parameter type: #{param_type_name}"
|
142
|
+
nil
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def normalize_parameter_value(parameter, raw_value)
|
147
|
+
return 0.0 if raw_value.nil?
|
148
|
+
|
149
|
+
normalizer = find_normalizer(parameter, raw_value)
|
150
|
+
if normalizer
|
151
|
+
normalized_val = normalizer.normalized_value.to_f
|
152
|
+
|
153
|
+
# Ensure normalized value is between 0 and 1
|
154
|
+
return [ [ normalized_val, 0.0 ].max, 1.0 ].min
|
155
|
+
end
|
156
|
+
|
157
|
+
Rails.logger.warn "No normalizer found for parameter '#{parameter.name}' with value '#{raw_value}'"
|
158
|
+
0.0
|
159
|
+
rescue StandardError => e
|
160
|
+
Rails.logger.error "Failed to normalize value '#{raw_value}' for parameter '#{parameter.name}': #{e.message}"
|
161
|
+
0.0 # Return 0 for any normalization errors
|
162
|
+
end
|
163
|
+
|
164
|
+
def find_normalizer(parameter, raw_value)
|
165
|
+
exact_match = parameter.parameter_normalizers.find { |normalizer| normalizer.raw_value == raw_value.to_s }
|
166
|
+
return exact_match if exact_match
|
167
|
+
|
168
|
+
if parameter.data_type.in?([ "integer", "decimal" ])
|
169
|
+
numeric_value = parse_numeric_value(raw_value)
|
170
|
+
return nil unless numeric_value
|
171
|
+
|
172
|
+
threshold_normalizer = parameter.parameter_normalizers.find do |normalizer|
|
173
|
+
evaluate_numeric_threshold(normalizer.raw_value, numeric_value)
|
174
|
+
end
|
175
|
+
|
176
|
+
return threshold_normalizer if threshold_normalizer
|
177
|
+
end
|
178
|
+
|
179
|
+
nil
|
180
|
+
end
|
181
|
+
|
182
|
+
def parse_numeric_value(raw_value)
|
183
|
+
return raw_value if raw_value.is_a?(Numeric)
|
184
|
+
|
185
|
+
begin
|
186
|
+
Float(raw_value.to_s)
|
187
|
+
rescue ArgumentError, TypeError
|
188
|
+
nil
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def evaluate_numeric_threshold(threshold_expression, numeric_value)
|
193
|
+
# Handle threshold expressions like ">=10000", "<5000", etc.
|
194
|
+
case threshold_expression
|
195
|
+
when /^>=(.+)$/
|
196
|
+
numeric_value >= Float($1)
|
197
|
+
when /^>(.+)$/
|
198
|
+
numeric_value > Float($1)
|
199
|
+
when /^<=(.+)$/
|
200
|
+
numeric_value <= Float($1)
|
201
|
+
when /^<(.+)$/
|
202
|
+
numeric_value < Float($1)
|
203
|
+
when /^=(.+)$/, /^(.+)$/ # Exact match or just the number
|
204
|
+
numeric_value == Float($1)
|
205
|
+
else
|
206
|
+
false
|
207
|
+
end
|
208
|
+
rescue ArgumentError, TypeError
|
209
|
+
false
|
210
|
+
end
|
211
|
+
|
212
|
+
def success_result(score_result)
|
213
|
+
final_score = score_result[:score]
|
214
|
+
review_status = determine_review_status(final_score)
|
215
|
+
|
216
|
+
{
|
217
|
+
success: true,
|
218
|
+
loan_application_id: loan_application.id,
|
219
|
+
bank_id: loan_application.bank_id,
|
220
|
+
category_id: category_id,
|
221
|
+
calculated_at: Time.current,
|
222
|
+
score: final_score,
|
223
|
+
status: review_status,
|
224
|
+
total_weighted_score: score_result[:total_weighted_score],
|
225
|
+
total_weight: score_result[:total_weight],
|
226
|
+
parameters_processed: score_result[:parameters_processed],
|
227
|
+
parameters_skipped: score_result[:parameters_skipped],
|
228
|
+
breakdown: score_result[:breakdown],
|
229
|
+
message: "Credit score calculated successfully"
|
230
|
+
}
|
231
|
+
end
|
232
|
+
|
233
|
+
def error_result(message)
|
234
|
+
@errors << message
|
235
|
+
{
|
236
|
+
success: false,
|
237
|
+
loan_application_id: loan_application&.id,
|
238
|
+
category_id: category_id,
|
239
|
+
error: message,
|
240
|
+
errors: @errors,
|
241
|
+
score: 0.0,
|
242
|
+
status: "rejected", # Errors result in rejection
|
243
|
+
calculated_at: Time.current
|
244
|
+
}
|
245
|
+
end
|
246
|
+
|
247
|
+
def determine_review_status(score)
|
248
|
+
case score
|
249
|
+
when 0...50
|
250
|
+
"rejected"
|
251
|
+
when 50..60
|
252
|
+
"pending_review"
|
253
|
+
else # score > 60
|
254
|
+
"approved"
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
module Dscf::Credit
|
2
|
+
class FacilityLimitCalculationEngine
|
3
|
+
attr_reader :loan_profile, :category_id, :errors
|
4
|
+
|
5
|
+
def initialize(loan_profile_id, category_id)
|
6
|
+
@errors = []
|
7
|
+
@loan_profile = LoanProfile.find(loan_profile_id)
|
8
|
+
@category_id = category_id
|
9
|
+
rescue ActiveRecord::RecordNotFound => e
|
10
|
+
@errors << "Loan profile not found: #{e.message}"
|
11
|
+
end
|
12
|
+
|
13
|
+
# Calculate facility limits for all credit lines in the category
|
14
|
+
def calculate_facility_limits
|
15
|
+
return error_result("Loan profile is required") unless loan_profile
|
16
|
+
return error_result("Category ID is required") unless category_id
|
17
|
+
|
18
|
+
begin
|
19
|
+
ActiveRecord::Base.transaction do
|
20
|
+
# Get all credit lines for the category and bank
|
21
|
+
credit_lines = get_credit_lines_for_calculation
|
22
|
+
|
23
|
+
if credit_lines.empty?
|
24
|
+
return error_result("No credit lines found for category #{category_id} and bank #{loan_profile.loan_application.bank_id}")
|
25
|
+
end
|
26
|
+
|
27
|
+
# Calculate base metric from facilitator info
|
28
|
+
base_metric = calculate_base_metric
|
29
|
+
|
30
|
+
if base_metric <= 0
|
31
|
+
return error_result("Invalid base metric calculated: #{base_metric}. Check facilitator_info avg_monthly_purchase.")
|
32
|
+
end
|
33
|
+
|
34
|
+
# Calculate facility limits for each credit line
|
35
|
+
eligible_credit_lines = []
|
36
|
+
|
37
|
+
credit_lines.each do |credit_line|
|
38
|
+
limit_result = calculate_credit_line_limit(credit_line, base_metric)
|
39
|
+
|
40
|
+
if limit_result[:success] && limit_result[:limit] >= 0
|
41
|
+
eligible_credit_line = create_eligible_credit_line(credit_line, limit_result[:limit])
|
42
|
+
eligible_credit_lines << eligible_credit_line
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Update loan profile total limit
|
47
|
+
update_loan_profile_total_limit
|
48
|
+
|
49
|
+
success_result(eligible_credit_lines, "Facility limits calculated successfully")
|
50
|
+
end
|
51
|
+
rescue StandardError => e
|
52
|
+
Rails.logger.error "Facility limit calculation failed: #{e.message}"
|
53
|
+
Rails.logger.error e.backtrace.join("\n")
|
54
|
+
error_result("Failed to calculate facility limits: #{e.message}")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# Get credit lines for the specified category and bank
|
61
|
+
def get_credit_lines_for_calculation
|
62
|
+
CreditLine.joins(:category, :credit_line_specs)
|
63
|
+
.where(
|
64
|
+
category_id: category_id,
|
65
|
+
bank_id: loan_profile.loan_application.bank_id,
|
66
|
+
dscf_credit_credit_line_specs: { active: true }
|
67
|
+
)
|
68
|
+
.includes(:credit_line_specs)
|
69
|
+
.distinct
|
70
|
+
end
|
71
|
+
|
72
|
+
# Calculate base metric from facilitator info
|
73
|
+
def calculate_base_metric
|
74
|
+
facilitator_info = loan_profile.loan_application.facilitator_info || {}
|
75
|
+
|
76
|
+
# Find avg_monthly_purchase from the nested JSON structure
|
77
|
+
# facilitator_info structure: { "301": { "param_name": "avg_monthly_purchase", "value": 30000 } }
|
78
|
+
avg_monthly_purchase = nil
|
79
|
+
|
80
|
+
facilitator_info.each do |param_id, param_data|
|
81
|
+
if param_data.is_a?(Hash) &&
|
82
|
+
(param_data["param_name"] == "avg_monthly_purchase" || param_data[:param_name] == "avg_monthly_purchase")
|
83
|
+
avg_monthly_purchase = param_data["value"] || param_data[:value]
|
84
|
+
break
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Handle string and numeric values
|
89
|
+
if avg_monthly_purchase.is_a?(String)
|
90
|
+
avg_monthly_purchase = avg_monthly_purchase.to_f
|
91
|
+
else
|
92
|
+
avg_monthly_purchase = avg_monthly_purchase.to_f if avg_monthly_purchase
|
93
|
+
end
|
94
|
+
|
95
|
+
return 0.0 if avg_monthly_purchase.nil? || avg_monthly_purchase <= 0
|
96
|
+
|
97
|
+
avg_monthly_purchase / 30.0
|
98
|
+
end
|
99
|
+
|
100
|
+
# Calculate facility limit for a specific credit line
|
101
|
+
def calculate_credit_line_limit(credit_line, base_metric)
|
102
|
+
credit_line_spec = credit_line.credit_line_specs.active.order(created_at: :desc).first
|
103
|
+
|
104
|
+
unless credit_line_spec
|
105
|
+
return { success: false, error: "No active credit line spec found for credit line #{credit_line.id}" }
|
106
|
+
end
|
107
|
+
|
108
|
+
score_percentage = loan_profile.score / 100.0
|
109
|
+
calculated_limit = base_metric * credit_line_spec.credit_line_multiplier * score_percentage
|
110
|
+
|
111
|
+
final_limit = apply_min_max_constraints(calculated_limit, credit_line_spec)
|
112
|
+
|
113
|
+
{ success: true, limit: final_limit, credit_line_spec: credit_line_spec }
|
114
|
+
end
|
115
|
+
|
116
|
+
def apply_min_max_constraints(calculated_limit, credit_line_spec)
|
117
|
+
if calculated_limit > credit_line_spec.max_amount
|
118
|
+
return credit_line_spec.max_amount
|
119
|
+
end
|
120
|
+
|
121
|
+
if calculated_limit < credit_line_spec.min_amount
|
122
|
+
return 0.0
|
123
|
+
end
|
124
|
+
|
125
|
+
calculated_limit
|
126
|
+
end
|
127
|
+
|
128
|
+
def create_eligible_credit_line(credit_line, limit)
|
129
|
+
loan_profile.eligible_credit_lines.create!(
|
130
|
+
credit_line: credit_line,
|
131
|
+
credit_limit: limit,
|
132
|
+
available_limit: limit,
|
133
|
+
risk: nil
|
134
|
+
)
|
135
|
+
end
|
136
|
+
|
137
|
+
def update_loan_profile_total_limit
|
138
|
+
total_limit = loan_profile.eligible_credit_lines.sum(:available_limit)
|
139
|
+
loan_profile.update!(total_limit: total_limit)
|
140
|
+
end
|
141
|
+
|
142
|
+
def success_result(data, message)
|
143
|
+
{
|
144
|
+
success: true,
|
145
|
+
data: data,
|
146
|
+
message: message
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
def error_result(error_message)
|
151
|
+
@errors << error_message unless @errors.include?(error_message)
|
152
|
+
{
|
153
|
+
success: false,
|
154
|
+
error: error_message,
|
155
|
+
errors: @errors
|
156
|
+
}
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module Dscf::Credit
|
2
|
+
class LoanProfileCreationService
|
3
|
+
attr_reader :loan_application, :score, :errors
|
4
|
+
|
5
|
+
def initialize(loan_application, score)
|
6
|
+
@loan_application = loan_application
|
7
|
+
@score = score
|
8
|
+
@errors = []
|
9
|
+
end
|
10
|
+
|
11
|
+
def create_loan_profile
|
12
|
+
return error_result("Loan application is required") unless loan_application
|
13
|
+
return error_result("Score is required") unless score
|
14
|
+
|
15
|
+
if loan_application.loan_profile.present?
|
16
|
+
return success_result(loan_application.loan_profile, "Loan profile already exists")
|
17
|
+
end
|
18
|
+
|
19
|
+
begin
|
20
|
+
ActiveRecord::Base.transaction do
|
21
|
+
loan_profile = build_loan_profile
|
22
|
+
loan_profile.save!
|
23
|
+
|
24
|
+
create_initial_review(loan_profile)
|
25
|
+
|
26
|
+
success_result(loan_profile, "Loan profile created successfully")
|
27
|
+
end
|
28
|
+
rescue StandardError => e
|
29
|
+
Rails.logger.error "Loan profile creation failed: #{e.message}"
|
30
|
+
Rails.logger.error e.backtrace.join("\n")
|
31
|
+
error_result("Failed to create loan profile: #{e.message}")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def build_loan_profile
|
38
|
+
loan_application.build_loan_profile(
|
39
|
+
code: generate_loan_profile_code,
|
40
|
+
score: score,
|
41
|
+
total_limit: calculate_total_limit
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
def generate_loan_profile_code
|
46
|
+
# Generate unique code starting with "BB" followed by up to 6 digits
|
47
|
+
loop do
|
48
|
+
# Generate a random 6-digit number
|
49
|
+
random_digits = sprintf("%06d", rand(1000000))
|
50
|
+
code = "BB#{random_digits}"
|
51
|
+
|
52
|
+
unless Dscf::Credit::LoanProfile.exists?(code: code)
|
53
|
+
return code
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def calculate_total_limit
|
59
|
+
0.0
|
60
|
+
end
|
61
|
+
|
62
|
+
def create_initial_review(loan_profile)
|
63
|
+
loan_profile.reviews.create!(
|
64
|
+
context: "default",
|
65
|
+
status: "approved",
|
66
|
+
reviewed_by: nil, # System created
|
67
|
+
reviewed_at: Time.current,
|
68
|
+
feedback: {
|
69
|
+
message: "Loan profile automatically created for approved loan application"
|
70
|
+
}
|
71
|
+
)
|
72
|
+
end
|
73
|
+
|
74
|
+
def success_result(loan_profile, message)
|
75
|
+
{
|
76
|
+
success: true,
|
77
|
+
loan_profile: loan_profile,
|
78
|
+
message: message
|
79
|
+
}
|
80
|
+
end
|
81
|
+
|
82
|
+
def error_result(message)
|
83
|
+
@errors << message
|
84
|
+
{
|
85
|
+
success: false,
|
86
|
+
error: message,
|
87
|
+
errors: @errors
|
88
|
+
}
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -1,27 +1,77 @@
|
|
1
1
|
module Dscf::Credit
|
2
2
|
class RiskApplicationService
|
3
|
-
|
3
|
+
attr_reader :eligible_credit_line, :risk_factor, :errors
|
4
|
+
|
5
|
+
def initialize(eligible_credit_line, risk_factor)
|
4
6
|
@eligible_credit_line = eligible_credit_line
|
5
|
-
@
|
7
|
+
@risk_factor = risk_factor.nil? ? nil : risk_factor.to_f
|
8
|
+
@errors = []
|
6
9
|
end
|
7
10
|
|
8
11
|
def apply_risk
|
9
|
-
return
|
12
|
+
return error_result("Eligible credit line is required") unless eligible_credit_line
|
13
|
+
return error_result("Risk factor is required") if risk_factor.nil?
|
14
|
+
return error_result("Risk factor must be between 0.0 and 0.9") unless valid_risk_factor?
|
15
|
+
|
16
|
+
begin
|
17
|
+
ActiveRecord::Base.transaction do
|
18
|
+
eligible_credit_line.risk = risk_factor
|
19
|
+
|
20
|
+
new_available_limit = calculate_available_limit_with_risk
|
21
|
+
eligible_credit_line.available_limit = new_available_limit
|
22
|
+
|
23
|
+
eligible_credit_line.save!
|
10
24
|
|
11
|
-
|
12
|
-
@eligible_credit_line.available_limit = calculate_available_limit
|
25
|
+
update_loan_profile_total_limit
|
13
26
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
27
|
+
success_result(eligible_credit_line, "Risk factor applied successfully")
|
28
|
+
end
|
29
|
+
rescue StandardError => e
|
30
|
+
Rails.logger.error "Risk application failed: #{e.message}"
|
31
|
+
Rails.logger.error e.backtrace.join("\n")
|
32
|
+
error_result("Failed to apply risk factor: #{e.message}")
|
18
33
|
end
|
19
34
|
end
|
20
35
|
|
21
36
|
private
|
22
37
|
|
23
|
-
def
|
24
|
-
|
38
|
+
def valid_risk_factor?
|
39
|
+
risk_factor >= 0.0 && risk_factor <= 0.9
|
40
|
+
end
|
41
|
+
|
42
|
+
def calculate_available_limit_with_risk
|
43
|
+
# available_limit = credit_limit × (1 - risk_factor)
|
44
|
+
# If risk_factor is 0.0 (0%), available_limit = credit_limit
|
45
|
+
# If risk_factor is 0.9 (90%), available_limit = credit_limit × 0.1
|
46
|
+
result = eligible_credit_line.credit_limit * (1 - risk_factor)
|
47
|
+
result.round(2)
|
48
|
+
end
|
49
|
+
|
50
|
+
def update_loan_profile_total_limit
|
51
|
+
loan_profile = eligible_credit_line.loan_profile
|
52
|
+
return unless loan_profile
|
53
|
+
|
54
|
+
total_available_limit = loan_profile.eligible_credit_lines.sum(:available_limit)
|
55
|
+
|
56
|
+
loan_profile.update!(total_limit: total_available_limit)
|
57
|
+
|
58
|
+
Rails.logger.info "Updated loan profile #{loan_profile.id} total limit to #{total_available_limit}"
|
59
|
+
end
|
60
|
+
|
61
|
+
def success_result(data, message)
|
62
|
+
{
|
63
|
+
success: true,
|
64
|
+
data: data,
|
65
|
+
message: message
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
def error_result(message)
|
70
|
+
{
|
71
|
+
success: false,
|
72
|
+
error: message,
|
73
|
+
errors: [ message ]
|
74
|
+
}
|
25
75
|
end
|
26
76
|
end
|
27
77
|
end
|