dscf-credit 0.1.4 → 0.1.6
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/disbursements_controller.rb +15 -16
- 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/loans_controller.rb +7 -7
- 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.rb +4 -4
- 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/loan_serializer.rb +1 -2
- 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/disbursement_service.rb +39 -84
- 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 +65 -50
- data/config/routes.rb +30 -18
- 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/migrate/20250822092654_create_dscf_credit_loans.rb +3 -1
- data/db/seeds.rb +321 -290
- 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/loans.rb +5 -1
- 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 -20
- data/app/controllers/concerns/dscf/credit/reviewable.rb +0 -112
- data/app/controllers/dscf/credit/payment_requests_controller.rb +0 -87
- data/app/controllers/dscf/credit/payments_controller.rb +0 -36
- data/app/controllers/dscf/credit/scoring_tables_controller.rb +0 -63
- data/app/models/dscf/credit/payment.rb +0 -22
- data/app/models/dscf/credit/payment_request.rb +0 -29
- data/app/models/dscf/credit/scoring_table.rb +0 -24
- data/app/serializers/dscf/credit/payment_request_serializer.rb +0 -10
- data/app/serializers/dscf/credit/payment_serializer.rb +0 -8
- data/app/serializers/dscf/credit/scoring_table_serializer.rb +0 -9
- data/db/migrate/20250822092608_create_dscf_credit_payment_requests.rb +0 -26
- data/db/migrate/20250822092843_create_dscf_credit_payments.rb +0 -23
- data/db/migrate/20250901172842_create_dscf_credit_scoring_tables.rb +0 -18
- data/spec/factories/dscf/credit/payment_requests.rb +0 -40
- data/spec/factories/dscf/credit/payments.rb +0 -39
- data/spec/factories/dscf/credit/scoring_tables.rb +0 -25
@@ -1,11 +1,10 @@
|
|
1
1
|
module Dscf::Credit
|
2
2
|
class LoanSerializer < ActiveModel::Serializer
|
3
3
|
attributes :id, :status, :principal_amount, :accrued_interest, :accrued_penalty, :facilitation_fee,
|
4
|
-
:total_loan_amount, :remaining_amount, :due_date, :disbursed_at, :created_at, :updated_at
|
4
|
+
:total_loan_amount, :remaining_amount, :due_date, :disbursed_at, :active, :created_at, :updated_at
|
5
5
|
|
6
6
|
belongs_to :loan_profile, serializer: LoanProfileSerializer
|
7
7
|
belongs_to :credit_line, serializer: CreditLineSerializer
|
8
|
-
belongs_to :payment_request, serializer: PaymentRequestSerializer
|
9
8
|
has_many :loan_transactions, serializer: LoanTransactionSerializer
|
10
9
|
has_many :daily_routine_transactions, serializer: DailyRoutineTransactionSerializer
|
11
10
|
end
|
@@ -1,15 +1,14 @@
|
|
1
1
|
module Dscf::Credit
|
2
2
|
class ScoringParameterSerializer < ActiveModel::Serializer
|
3
3
|
attributes :id, :name, :description, :data_type, :weight, :min_value,
|
4
|
-
:max_value, :active, :
|
4
|
+
:max_value, :active, :source, :document_reference, :created_at, :updated_at
|
5
5
|
|
6
6
|
belongs_to :bank, serializer: Dscf::Credit::BankSerializer
|
7
|
+
belongs_to :category, serializer: Dscf::Credit::CategorySerializer
|
7
8
|
belongs_to :created_by, polymorphic: true
|
8
|
-
belongs_to :reviewed_by, polymorphic: true
|
9
9
|
belongs_to :scoring_param_type, serializer: Dscf::Credit::ScoringParamTypeSerializer
|
10
10
|
belongs_to :previous_version, serializer: Dscf::Credit::ScoringParameterSerializer
|
11
11
|
has_many :parameter_normalizers, serializer: Dscf::Credit::ParameterNormalizerSerializer
|
12
|
-
has_many :
|
13
|
-
has_many :categories, serializer: Dscf::Credit::CategorySerializer
|
12
|
+
has_many :reviews, serializer: Dscf::Core::ReviewSerializer
|
14
13
|
end
|
15
14
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
module Dscf::Credit
|
2
2
|
class SystemConfigSerializer < ActiveModel::Serializer
|
3
|
-
attributes :id, :config_value, :
|
3
|
+
attributes :id, :config_value, :created_at, :updated_at
|
4
4
|
|
5
5
|
attribute :config_definition do
|
6
6
|
{
|
@@ -13,6 +13,6 @@ module Dscf::Credit
|
|
13
13
|
end
|
14
14
|
|
15
15
|
belongs_to :last_updated_by, serializer: Dscf::Core::UserSerializer
|
16
|
-
|
16
|
+
has_many :reviews, serializer: Dscf::Core::ReviewSerializer
|
17
17
|
end
|
18
18
|
end
|
@@ -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
|
@@ -1,34 +1,35 @@
|
|
1
1
|
module Dscf::Credit
|
2
2
|
class DisbursementService
|
3
|
-
attr_reader :
|
3
|
+
attr_reader :amount, :loan_profile, :eligible_credit_line
|
4
4
|
|
5
|
-
def initialize(
|
6
|
-
@
|
7
|
-
@
|
8
|
-
@
|
5
|
+
def initialize(amount:, loan_profile:, eligible_credit_line:)
|
6
|
+
@amount = amount.to_f
|
7
|
+
@loan_profile = loan_profile
|
8
|
+
@eligible_credit_line = eligible_credit_line
|
9
9
|
end
|
10
10
|
|
11
|
-
# Process disbursement by creating a loan record from
|
11
|
+
# Process disbursement by creating a loan record from eligible credit line
|
12
12
|
# @return [Hash] Result containing created loan and disbursement details
|
13
13
|
def process_disbursement
|
14
|
+
return error_result("Loan profile not found") unless loan_profile
|
15
|
+
return error_result("Eligible credit line not found") unless eligible_credit_line
|
16
|
+
return error_result("Invalid amount") unless amount > 0
|
17
|
+
|
18
|
+
credit_line = eligible_credit_line.credit_line
|
14
19
|
return error_result("Credit line not found") unless credit_line
|
15
|
-
return error_result("Payment request not found") unless payment_request
|
16
|
-
return error_result("Credit line is not approved") unless credit_line.status == "approved"
|
17
20
|
|
18
|
-
|
19
|
-
return error_result("
|
21
|
+
credit_line_status = credit_line.current_status_for(:default)
|
22
|
+
return error_result("Credit line is not approved") unless credit_line_status == "approved"
|
20
23
|
|
21
|
-
|
22
|
-
return error_result("Loan profile not approved") unless
|
24
|
+
loan_profile_status = loan_profile.current_status_for(:default)
|
25
|
+
return error_result("Loan profile not approved") unless loan_profile_status == "approved"
|
23
26
|
|
24
|
-
validation_result = validate_disbursement_amount
|
27
|
+
validation_result = validate_disbursement_amount
|
25
28
|
return validation_result unless validation_result[:success]
|
26
29
|
|
27
30
|
ActiveRecord::Base.transaction do
|
28
|
-
loan = create_loan_record(
|
29
|
-
|
30
|
-
update_credit_line_limits(eligible_credit_line, loan)
|
31
|
-
lock_other_credit_lines(loan_profile, credit_line)
|
31
|
+
loan = create_loan_record(credit_line)
|
32
|
+
update_credit_line_limits(loan)
|
32
33
|
|
33
34
|
success_result(loan)
|
34
35
|
end
|
@@ -38,36 +39,27 @@ module Dscf::Credit
|
|
38
39
|
|
39
40
|
private
|
40
41
|
|
41
|
-
def
|
42
|
-
credit_line.eligible_credit_lines
|
43
|
-
.joins(:loan_profile)
|
44
|
-
.where(dscf_credit_loan_profiles: { status: "approved" })
|
45
|
-
.first
|
46
|
-
end
|
47
|
-
|
48
|
-
def validate_disbursement_amount(eligible_credit_line)
|
49
|
-
requested_amount = payment_request.amount
|
42
|
+
def validate_disbursement_amount
|
50
43
|
available_limit = eligible_credit_line.available_limit
|
51
44
|
|
52
|
-
if
|
45
|
+
if amount <= 0
|
53
46
|
return error_result("Invalid disbursement amount")
|
54
47
|
end
|
55
48
|
|
56
|
-
if
|
49
|
+
if amount > available_limit
|
57
50
|
return error_result("Requested amount exceeds available credit limit")
|
58
51
|
end
|
59
52
|
|
60
53
|
{ success: true }
|
61
54
|
end
|
62
55
|
|
63
|
-
def create_loan_record(
|
64
|
-
loan_terms = calculate_loan_terms(
|
56
|
+
def create_loan_record(credit_line)
|
57
|
+
loan_terms = calculate_loan_terms(credit_line)
|
65
58
|
|
66
59
|
loan = Dscf::Credit::Loan.new(
|
67
60
|
loan_profile: loan_profile,
|
68
61
|
credit_line: credit_line,
|
69
|
-
|
70
|
-
principal_amount: payment_request.amount,
|
62
|
+
principal_amount: amount,
|
71
63
|
facilitation_fee: loan_terms[:facilitation_fee],
|
72
64
|
total_loan_amount: loan_terms[:total_amount],
|
73
65
|
remaining_amount: loan_terms[:total_amount],
|
@@ -75,7 +67,8 @@ module Dscf::Credit
|
|
75
67
|
accrued_penalty: 0,
|
76
68
|
status: "disbursed",
|
77
69
|
due_date: loan_terms[:due_date],
|
78
|
-
disbursed_at: Time.current
|
70
|
+
disbursed_at: Time.current,
|
71
|
+
active: true
|
79
72
|
)
|
80
73
|
|
81
74
|
raise "Failed to create loan: #{loan.errors.full_messages.join(', ')}" unless loan.save
|
@@ -83,21 +76,20 @@ module Dscf::Credit
|
|
83
76
|
loan
|
84
77
|
end
|
85
78
|
|
86
|
-
def calculate_loan_terms(
|
87
|
-
principal =
|
79
|
+
def calculate_loan_terms(credit_line)
|
80
|
+
principal = amount
|
88
81
|
|
89
82
|
credit_line_spec = credit_line.credit_line_specs.active.first
|
90
83
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
loan_duration = credit_line_spec.loan_duration
|
95
|
-
else
|
96
|
-
facilitation_rate = 0.02 # 2%
|
97
|
-
vat_rate = 0.15 # 15%
|
98
|
-
loan_duration = 30 # 30 days
|
84
|
+
unless credit_line_spec
|
85
|
+
raise "No active credit line specification found for credit line #{credit_line.id}. " \
|
86
|
+
"Please configure a CreditLineSpec before processing disbursements."
|
99
87
|
end
|
100
88
|
|
89
|
+
facilitation_rate = credit_line_spec.facilitation_fee_rate
|
90
|
+
vat_rate = credit_line_spec.vat
|
91
|
+
loan_duration = credit_line_spec.loan_duration
|
92
|
+
|
101
93
|
facilitation_fee = principal * facilitation_rate
|
102
94
|
vat_amount = facilitation_fee * vat_rate
|
103
95
|
total_amount = principal + facilitation_fee + vat_amount
|
@@ -112,46 +104,9 @@ module Dscf::Credit
|
|
112
104
|
}
|
113
105
|
end
|
114
106
|
|
115
|
-
def
|
116
|
-
payment_request.update!(
|
117
|
-
status: "processed",
|
118
|
-
approved_at: Time.current
|
119
|
-
)
|
120
|
-
|
121
|
-
Dscf::Credit::LoanTransaction.create!(
|
122
|
-
loan: loan,
|
123
|
-
transaction_type: "disbursement",
|
124
|
-
amount: loan.principal_amount,
|
125
|
-
transaction_reference: "DISB-#{payment_request.order_id}-#{Time.current.to_i}",
|
126
|
-
status: "completed"
|
127
|
-
)
|
128
|
-
|
129
|
-
if loan.facilitation_fee > 0
|
130
|
-
Dscf::Credit::LoanTransaction.create!(
|
131
|
-
loan: loan,
|
132
|
-
transaction_type: "fee_charge",
|
133
|
-
amount: loan.facilitation_fee,
|
134
|
-
transaction_reference: "FEE-#{loan.id}-#{Time.current.to_i}",
|
135
|
-
status: "completed"
|
136
|
-
)
|
137
|
-
end
|
138
|
-
end
|
139
|
-
|
140
|
-
def update_credit_line_limits(eligible_credit_line, loan)
|
107
|
+
def update_credit_line_limits(loan)
|
141
108
|
new_available_limit = eligible_credit_line.available_limit - loan.total_loan_amount
|
142
109
|
eligible_credit_line.update!(available_limit: [ new_available_limit, 0 ].max)
|
143
|
-
|
144
|
-
# Update loan profile available amount
|
145
|
-
loan_profile = eligible_credit_line.loan_profile
|
146
|
-
new_profile_available = loan_profile.available_amount - loan.total_loan_amount
|
147
|
-
loan_profile.update!(available_amount: [ new_profile_available, 0 ].max)
|
148
|
-
end
|
149
|
-
|
150
|
-
def lock_other_credit_lines(loan_profile, current_credit_line)
|
151
|
-
other_eligible_lines = loan_profile.eligible_credit_lines
|
152
|
-
.where.not(credit_line: current_credit_line)
|
153
|
-
|
154
|
-
other_eligible_lines.update_all(available_limit: 0)
|
155
110
|
end
|
156
111
|
|
157
112
|
def success_result(loan)
|
@@ -159,9 +114,9 @@ module Dscf::Credit
|
|
159
114
|
success: true,
|
160
115
|
loan: loan,
|
161
116
|
disbursement_details: {
|
162
|
-
principal_amount: loan.principal_amount,
|
163
|
-
facilitation_fee: loan.facilitation_fee,
|
164
|
-
total_loan_amount: loan.total_loan_amount,
|
117
|
+
principal_amount: loan.principal_amount.to_f,
|
118
|
+
facilitation_fee: loan.facilitation_fee.to_f,
|
119
|
+
total_loan_amount: loan.total_loan_amount.to_f,
|
165
120
|
due_date: loan.due_date,
|
166
121
|
disbursed_at: loan.disbursed_at
|
167
122
|
},
|
@@ -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
|