dscf-credit 0.2.5 → 0.2.7
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/serializers/dscf/credit/loan_profile_serializer.rb +5 -1
- data/app/serializers/dscf/credit/loan_serializer.rb +5 -0
- data/app/services/dscf/credit/credit_scoring_engine.rb +91 -73
- data/app/services/dscf/credit/disbursement_service.rb +14 -26
- data/app/services/dscf/credit/repayment_service.rb +11 -27
- data/lib/dscf/credit/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 36671cda01df2f1f1b75625ccd3bb1ba23b16f97d410c498d89f0a93af02ac84
|
4
|
+
data.tar.gz: d84818826abcfb81ad715806b7383c1ed3aa64672e7e58460af664e65c46ca0f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: df917f92170265597d7a3dacbab1f099fdcf41fb7c8c2c5c1a37733776aa650d28dd11367064a899edd2e210316c9ebacaf11576f7196dd1014f745064711b1d
|
7
|
+
data.tar.gz: 26b4b04ca0313bff6f1fd9d8c15116f335c57dac0489f19383b1bf7849280d648338749fedc464401943a5845996ec46cfe84024eecac3adc985ecadbbbf31a3
|
@@ -1,6 +1,10 @@
|
|
1
1
|
module Dscf::Credit
|
2
2
|
class LoanProfileSerializer < ActiveModel::Serializer
|
3
|
-
attributes :id, :code, :score, :total_limit, :created_at, :updated_at
|
3
|
+
attributes :id, :code, :score, :total_limit, :created_at, :updated_at, :user_id
|
4
|
+
|
5
|
+
attribute :user_id do
|
6
|
+
object.loan_application.user_id
|
7
|
+
end
|
4
8
|
|
5
9
|
belongs_to :loan_application, serializer: Dscf::Credit::LoanApplicationSerializer
|
6
10
|
|
@@ -12,9 +12,14 @@ module Dscf::Credit
|
|
12
12
|
:total_facilitation_fee,
|
13
13
|
:total_outstanding,
|
14
14
|
:active,
|
15
|
+
:user_id,
|
15
16
|
:created_at,
|
16
17
|
:updated_at
|
17
18
|
|
19
|
+
attribute :user_id do
|
20
|
+
object.loan_profile.loan_application.user_id
|
21
|
+
end
|
22
|
+
|
18
23
|
belongs_to :loan_profile, serializer: LoanProfileSerializer
|
19
24
|
belongs_to :credit_line, serializer: CreditLineSerializer
|
20
25
|
has_many :loan_transactions, serializer: LoanTransactionSerializer
|
@@ -46,7 +46,7 @@ module Dscf::Credit
|
|
46
46
|
.scoring_parameters
|
47
47
|
.active
|
48
48
|
.where(scoring_param_type_id: scoring_param_type_id)
|
49
|
-
.includes(:scoring_param_type, :
|
49
|
+
.includes(:scoring_param_type, :category)
|
50
50
|
end
|
51
51
|
|
52
52
|
def calculate_weighted_score(scoring_parameters)
|
@@ -54,13 +54,15 @@ module Dscf::Credit
|
|
54
54
|
total_weight = 0.0
|
55
55
|
score_breakdown = []
|
56
56
|
parameters_processed = 0
|
57
|
-
|
57
|
+
validation_errors = []
|
58
58
|
|
59
59
|
scoring_parameters.each do |parameter|
|
60
60
|
raw_value = extract_parameter_value(parameter)
|
61
61
|
|
62
62
|
if raw_value.nil?
|
63
|
-
|
63
|
+
error_msg = "Missing required parameter: #{parameter.name} (ID: #{parameter.id})"
|
64
|
+
validation_errors << error_msg
|
65
|
+
Rails.logger.error "Credit scoring validation failed: #{error_msg}"
|
64
66
|
score_breakdown << {
|
65
67
|
parameter_id: parameter.id,
|
66
68
|
parameter_name: parameter.name,
|
@@ -69,12 +71,54 @@ module Dscf::Credit
|
|
69
71
|
normalized_value: nil,
|
70
72
|
weight: parameter.weight,
|
71
73
|
weighted_score: 0.0,
|
72
|
-
status: "missing"
|
74
|
+
status: "missing",
|
75
|
+
error: "Parameter value is required"
|
76
|
+
}
|
77
|
+
next
|
78
|
+
end
|
79
|
+
|
80
|
+
unless raw_value.is_a?(Numeric)
|
81
|
+
begin
|
82
|
+
Float(raw_value)
|
83
|
+
rescue ArgumentError, TypeError
|
84
|
+
error_msg = "Invalid data type for parameter: #{parameter.name} (ID: #{parameter.id}). Expected numeric, got #{raw_value.class}"
|
85
|
+
validation_errors << error_msg
|
86
|
+
Rails.logger.error "Credit scoring validation failed: #{error_msg}"
|
87
|
+
score_breakdown << {
|
88
|
+
parameter_id: parameter.id,
|
89
|
+
parameter_name: parameter.name,
|
90
|
+
parameter_type: parameter.scoring_param_type.name,
|
91
|
+
raw_value: raw_value,
|
92
|
+
normalized_value: nil,
|
93
|
+
weight: parameter.weight,
|
94
|
+
weighted_score: 0.0,
|
95
|
+
status: "invalid",
|
96
|
+
error: "Value must be numeric"
|
97
|
+
}
|
98
|
+
next
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
normalized_value = raw_value.to_f
|
103
|
+
|
104
|
+
unless normalized_value.between?(0.0, 1.0)
|
105
|
+
error_msg = "Invalid normalized value for parameter: #{parameter.name} (ID: #{parameter.id}). Value: #{raw_value}, Expected: 0.0-1.0"
|
106
|
+
validation_errors << error_msg
|
107
|
+
Rails.logger.error "Credit scoring validation failed: #{error_msg}"
|
108
|
+
score_breakdown << {
|
109
|
+
parameter_id: parameter.id,
|
110
|
+
parameter_name: parameter.name,
|
111
|
+
parameter_type: parameter.scoring_param_type.name,
|
112
|
+
raw_value: raw_value,
|
113
|
+
normalized_value: normalized_value,
|
114
|
+
weight: parameter.weight,
|
115
|
+
weighted_score: 0.0,
|
116
|
+
status: "invalid",
|
117
|
+
error: "Value must be between 0.0 and 1.0"
|
73
118
|
}
|
74
119
|
next
|
75
120
|
end
|
76
121
|
|
77
|
-
normalized_value = normalize_parameter_value(parameter, raw_value)
|
78
122
|
weighted_score = normalized_value * parameter.weight
|
79
123
|
|
80
124
|
total_weighted_score += weighted_score
|
@@ -93,15 +137,38 @@ module Dscf::Credit
|
|
93
137
|
}
|
94
138
|
end
|
95
139
|
|
140
|
+
if validation_errors.any?
|
141
|
+
return {
|
142
|
+
success: false,
|
143
|
+
error: "Validation failed: #{validation_errors.size} parameter(s) failed validation",
|
144
|
+
validation_errors: validation_errors,
|
145
|
+
parameters_processed: parameters_processed,
|
146
|
+
parameters_failed: validation_errors.size,
|
147
|
+
breakdown: score_breakdown
|
148
|
+
}
|
149
|
+
end
|
150
|
+
|
151
|
+
if parameters_processed == 0
|
152
|
+
return {
|
153
|
+
success: false,
|
154
|
+
error: "No valid parameters available for scoring",
|
155
|
+
validation_errors: [ "All parameters failed validation or are missing" ],
|
156
|
+
parameters_processed: 0,
|
157
|
+
parameters_failed: scoring_parameters.size,
|
158
|
+
breakdown: score_breakdown
|
159
|
+
}
|
160
|
+
end
|
161
|
+
|
96
162
|
# Calculate final score using the formula: (sum of normalized_value × weight) / total_weight × 100
|
97
163
|
final_score = total_weight > 0 ? (total_weighted_score / total_weight * 100) : 0.0
|
98
164
|
|
99
165
|
{
|
166
|
+
success: true,
|
100
167
|
score: final_score.round(2),
|
101
168
|
total_weighted_score: total_weighted_score.round(4),
|
102
169
|
total_weight: total_weight.round(4),
|
103
170
|
parameters_processed: parameters_processed,
|
104
|
-
|
171
|
+
parameters_failed: 0,
|
105
172
|
breakdown: score_breakdown
|
106
173
|
}
|
107
174
|
end
|
@@ -148,74 +215,24 @@ module Dscf::Credit
|
|
148
215
|
raw_value
|
149
216
|
end
|
150
217
|
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
0.0 # Return 0 for any normalization errors
|
168
|
-
end
|
169
|
-
|
170
|
-
def find_normalizer(parameter, raw_value)
|
171
|
-
exact_match = parameter.parameter_normalizers.find { |normalizer| normalizer.raw_value == raw_value.to_s }
|
172
|
-
return exact_match if exact_match
|
173
|
-
|
174
|
-
if parameter.data_type.in?([ "integer", "decimal" ])
|
175
|
-
numeric_value = parse_numeric_value(raw_value)
|
176
|
-
return nil unless numeric_value
|
177
|
-
|
178
|
-
threshold_normalizer = parameter.parameter_normalizers.find do |normalizer|
|
179
|
-
evaluate_numeric_threshold(normalizer.raw_value, numeric_value)
|
180
|
-
end
|
181
|
-
|
182
|
-
return threshold_normalizer if threshold_normalizer
|
183
|
-
end
|
184
|
-
|
185
|
-
nil
|
186
|
-
end
|
187
|
-
|
188
|
-
def parse_numeric_value(raw_value)
|
189
|
-
return raw_value if raw_value.is_a?(Numeric)
|
190
|
-
|
191
|
-
begin
|
192
|
-
Float(raw_value.to_s)
|
193
|
-
rescue ArgumentError, TypeError
|
194
|
-
nil
|
195
|
-
end
|
196
|
-
end
|
197
|
-
|
198
|
-
def evaluate_numeric_threshold(threshold_expression, numeric_value)
|
199
|
-
# Handle threshold expressions like ">=10000", "<5000", etc.
|
200
|
-
case threshold_expression
|
201
|
-
when /^>=(.+)$/
|
202
|
-
numeric_value >= Float($1)
|
203
|
-
when /^>(.+)$/
|
204
|
-
numeric_value > Float($1)
|
205
|
-
when /^<=(.+)$/
|
206
|
-
numeric_value <= Float($1)
|
207
|
-
when /^<(.+)$/
|
208
|
-
numeric_value < Float($1)
|
209
|
-
when /^=(.+)$/, /^(.+)$/ # Exact match or just the number
|
210
|
-
numeric_value == Float($1)
|
211
|
-
else
|
212
|
-
false
|
218
|
+
def success_result(score_result)
|
219
|
+
if score_result[:success] == false
|
220
|
+
return {
|
221
|
+
success: false,
|
222
|
+
loan_application_id: loan_application.id,
|
223
|
+
bank_id: loan_application.bank_id,
|
224
|
+
scoring_param_type_id: scoring_param_type_id,
|
225
|
+
calculated_at: Time.current,
|
226
|
+
error: score_result[:error],
|
227
|
+
validation_errors: score_result[:validation_errors],
|
228
|
+
parameters_processed: score_result[:parameters_processed],
|
229
|
+
parameters_failed: score_result[:parameters_failed],
|
230
|
+
breakdown: score_result[:breakdown],
|
231
|
+
score: 0.0,
|
232
|
+
status: "rejected"
|
233
|
+
}
|
213
234
|
end
|
214
|
-
rescue ArgumentError, TypeError
|
215
|
-
false
|
216
|
-
end
|
217
235
|
|
218
|
-
def success_result(score_result)
|
219
236
|
final_score = score_result[:score]
|
220
237
|
review_status = determine_review_status(final_score)
|
221
238
|
|
@@ -230,7 +247,8 @@ module Dscf::Credit
|
|
230
247
|
total_weighted_score: score_result[:total_weighted_score],
|
231
248
|
total_weight: score_result[:total_weight],
|
232
249
|
parameters_processed: score_result[:parameters_processed],
|
233
|
-
parameters_skipped:
|
250
|
+
parameters_skipped: 0,
|
251
|
+
parameters_failed: score_result[:parameters_failed],
|
234
252
|
breakdown: score_result[:breakdown],
|
235
253
|
message: "Credit score calculated successfully"
|
236
254
|
}
|
@@ -30,6 +30,7 @@ module Dscf::Credit
|
|
30
30
|
ActiveRecord::Base.transaction do
|
31
31
|
loan = create_loan_record(credit_line)
|
32
32
|
update_credit_line_limits(loan)
|
33
|
+
lock_other_credit_lines(loan_profile, credit_line)
|
33
34
|
|
34
35
|
success_result(loan)
|
35
36
|
end
|
@@ -37,6 +38,15 @@ module Dscf::Credit
|
|
37
38
|
error_result("Disbursement processing failed: #{e.message}")
|
38
39
|
end
|
39
40
|
|
41
|
+
# Lock other eligible credit lines for the loan profile except the specified credit line
|
42
|
+
# @param loan_profile [Dscf::Credit::LoanProfile] The loan profile
|
43
|
+
# @param credit_line [Dscf::Credit::CreditLine] The credit line to exclude from locking
|
44
|
+
def lock_other_credit_lines(loan_profile, credit_line)
|
45
|
+
loan_profile.eligible_credit_lines
|
46
|
+
.where.not(credit_line: credit_line)
|
47
|
+
.update_all(locked: true)
|
48
|
+
end
|
49
|
+
|
40
50
|
private
|
41
51
|
|
42
52
|
def validate_disbursement_amount
|
@@ -72,9 +82,6 @@ module Dscf::Credit
|
|
72
82
|
# Create facilitator fee accrual
|
73
83
|
create_facilitator_fee_accrual(loan, loan_terms[:facilitation_fee])
|
74
84
|
|
75
|
-
# Create VAT accrual if applicable
|
76
|
-
create_vat_accrual(loan, loan_terms[:vat_amount]) if loan_terms[:vat_amount] > 0
|
77
|
-
|
78
85
|
loan
|
79
86
|
end
|
80
87
|
|
@@ -89,18 +96,15 @@ module Dscf::Credit
|
|
89
96
|
end
|
90
97
|
|
91
98
|
facilitation_rate = credit_line_spec.facilitation_fee_rate
|
92
|
-
vat_rate = credit_line_spec.vat
|
93
99
|
loan_duration = credit_line_spec.loan_duration
|
94
100
|
|
95
101
|
facilitation_fee = principal * facilitation_rate
|
96
|
-
|
97
|
-
total_amount = principal + facilitation_fee + vat_amount
|
102
|
+
total_amount = principal + facilitation_fee
|
98
103
|
|
99
104
|
due_date = Date.current + loan_duration.days
|
100
105
|
|
101
106
|
{
|
102
107
|
facilitation_fee: facilitation_fee.round(2),
|
103
|
-
vat_amount: vat_amount.round(2),
|
104
108
|
total_amount: total_amount.round(2),
|
105
109
|
due_date: due_date
|
106
110
|
}
|
@@ -118,23 +122,10 @@ module Dscf::Credit
|
|
118
122
|
)
|
119
123
|
end
|
120
124
|
|
121
|
-
def create_vat_accrual(loan, vat_amount)
|
122
|
-
return if vat_amount <= 0
|
123
|
-
|
124
|
-
Dscf::Credit::LoanAccrual.create!(
|
125
|
-
loan: loan,
|
126
|
-
accrual_type: "tax",
|
127
|
-
amount: vat_amount,
|
128
|
-
applied_on: Date.current,
|
129
|
-
status: "pending"
|
130
|
-
)
|
131
|
-
end
|
132
|
-
|
133
125
|
def update_credit_line_limits(loan)
|
134
|
-
# Calculate total amount from accruals: principal + facilitation_fee
|
126
|
+
# Calculate total amount from accruals: principal + facilitation_fee
|
135
127
|
facilitation_fee = loan.loan_accruals.find_by(accrual_type: "facilitation_fee")&.amount || 0
|
136
|
-
|
137
|
-
total_amount = loan.principal_amount + facilitation_fee + vat
|
128
|
+
total_amount = loan.principal_amount + facilitation_fee
|
138
129
|
|
139
130
|
new_available_limit = eligible_credit_line.available_limit - total_amount
|
140
131
|
eligible_credit_line.update!(available_limit: [ new_available_limit, 0 ].max)
|
@@ -145,11 +136,9 @@ module Dscf::Credit
|
|
145
136
|
loan.reload
|
146
137
|
|
147
138
|
facilitation_fee_accrual = loan.loan_accruals.find_by(accrual_type: "facilitation_fee")
|
148
|
-
vat_accrual = loan.loan_accruals.find_by(accrual_type: "tax")
|
149
139
|
|
150
140
|
facilitation_fee = facilitation_fee_accrual&.amount&.to_f || 0.0
|
151
|
-
|
152
|
-
total_amount = loan.principal_amount.to_f + facilitation_fee + vat_amount
|
141
|
+
total_amount = loan.principal_amount.to_f + facilitation_fee
|
153
142
|
|
154
143
|
{
|
155
144
|
success: true,
|
@@ -157,7 +146,6 @@ module Dscf::Credit
|
|
157
146
|
disbursement_details: {
|
158
147
|
principal_amount: loan.principal_amount.to_f,
|
159
148
|
facilitation_fee: facilitation_fee,
|
160
|
-
vat_amount: vat_amount,
|
161
149
|
total_loan_amount: total_amount,
|
162
150
|
due_date: loan.due_date,
|
163
151
|
disbursed_at: loan.disbursed_at
|
@@ -250,7 +250,7 @@ module Dscf::Credit
|
|
250
250
|
(loan.remaining_amount || 0)
|
251
251
|
|
252
252
|
if total_remaining <= 0.01
|
253
|
-
loan.update!(status: "paid")
|
253
|
+
loan.update!(status: "paid", active: false)
|
254
254
|
else
|
255
255
|
loan.update!(status: "active") if loan.status == "overdue"
|
256
256
|
end
|
@@ -261,46 +261,30 @@ module Dscf::Credit
|
|
261
261
|
# Only runs if loan.status == "paid"
|
262
262
|
#
|
263
263
|
# Process:
|
264
|
-
# 1. Find all eligible credit lines
|
264
|
+
# 1. Find all locked eligible credit lines (locked during disbursement)
|
265
265
|
# 2. For each locked line, recalculate available_limit based on:
|
266
266
|
# - Credit limit (total allowed)
|
267
267
|
# - Minus current usage from other active loans
|
268
|
+
# 3. Unlock the credit line by setting locked: false
|
268
269
|
#
|
269
270
|
# This allows the borrower to access credit again after repayment.
|
270
271
|
#
|
271
272
|
# @return [void]
|
272
273
|
#
|
273
274
|
# @example
|
274
|
-
# # Before: eligible_line.
|
275
|
-
# # After payment: eligible_line.
|
275
|
+
# # Before: eligible_line.locked = true
|
276
|
+
# # After payment: eligible_line.locked = false
|
276
277
|
def reactivate_facilities_if_paid_off
|
277
278
|
return unless loan.status == "paid"
|
278
279
|
|
279
280
|
loan_profile = loan.loan_profile
|
281
|
+
credit_line = loan.credit_line
|
280
282
|
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
current_usage = calculate_current_usage_for_credit_line(eligible_line.credit_line)
|
287
|
-
new_available_limit = [ eligible_line.credit_limit - current_usage, 0 ].max
|
288
|
-
|
289
|
-
eligible_line.update!(available_limit: new_available_limit)
|
290
|
-
end
|
291
|
-
end
|
292
|
-
|
293
|
-
# Calculate total current usage for a credit line from all active loans
|
294
|
-
#
|
295
|
-
# Excludes the current loan (the one being paid off) from calculation.
|
296
|
-
#
|
297
|
-
# @param credit_line [Dscf::Credit::CreditLine] The credit line to check
|
298
|
-
# @return [Float] Sum of remaining_amount from active loans
|
299
|
-
def calculate_current_usage_for_credit_line(credit_line)
|
300
|
-
credit_line.loans
|
301
|
-
.where(status: [ "active", "overdue", "disbursed" ])
|
302
|
-
.where.not(id: loan.id)
|
303
|
-
.sum(:remaining_amount)
|
283
|
+
# Unlock other eligible credit lines that were locked during disbursement
|
284
|
+
loan_profile.eligible_credit_lines
|
285
|
+
.where.not(credit_line: credit_line)
|
286
|
+
.where(locked: true)
|
287
|
+
.update_all(locked: false)
|
304
288
|
end
|
305
289
|
|
306
290
|
# Build success result hash
|
data/lib/dscf/credit/version.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dscf-credit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Adoniyas
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-10-
|
10
|
+
date: 2025-10-09 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: dscf-core
|