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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9f8c9b62c4ec5893c8ab1b234073b103d9eb78370d133bd1e0b40b56aea1fd0
4
- data.tar.gz: 12447006ba8f6896e6316cbf20093b3527454ca144659b00839e435284adea9d
3
+ metadata.gz: 36671cda01df2f1f1b75625ccd3bb1ba23b16f97d410c498d89f0a93af02ac84
4
+ data.tar.gz: d84818826abcfb81ad715806b7383c1ed3aa64672e7e58460af664e65c46ca0f
5
5
  SHA512:
6
- metadata.gz: d5672372c71088c52598cc2839fd5f6b71955ad9b781b3e2c568a37407858b5cdeec1cc37029208000f77cd3bb1d66d9f21a896abda6f48aab182f21436b0fdd
7
- data.tar.gz: 630a449898f52513090be1aacb501a29ec977492f67e8a2e097056360ac83188bd632ddf754baaf1a745ebc1660599c29332e1f466b2de5aafffa899888d7f5f
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, :parameter_normalizers, :category)
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
- parameters_skipped = 0
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
- parameters_skipped += 1
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
- parameters_skipped: parameters_skipped,
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
- def normalize_parameter_value(parameter, raw_value)
153
- return 0.0 if raw_value.nil?
154
-
155
- normalizer = find_normalizer(parameter, raw_value)
156
- if normalizer
157
- normalized_val = normalizer.normalized_value.to_f
158
-
159
- # Ensure normalized value is between 0 and 1
160
- return [ [ normalized_val, 0.0 ].max, 1.0 ].min
161
- end
162
-
163
- Rails.logger.warn "No normalizer found for parameter '#{parameter.name}' with value '#{raw_value}'"
164
- 0.0
165
- rescue StandardError => e
166
- Rails.logger.error "Failed to normalize value '#{raw_value}' for parameter '#{parameter.name}': #{e.message}"
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: score_result[: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
- vat_amount = facilitation_fee * vat_rate
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 + vat
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
- vat = loan.loan_accruals.find_by(accrual_type: "tax")&.amount || 0
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
- vat_amount = vat_accrual&.amount&.to_f || 0.0
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 with 0 available_limit (locked)
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.available_limit = 0 (locked)
275
- # # After payment: eligible_line.available_limit = 7000 (reactivated)
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
- locked_eligible_lines = loan_profile.eligible_credit_lines
282
- .where(available_limit: 0)
283
- .includes(:credit_line)
284
-
285
- locked_eligible_lines.each do |eligible_line|
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
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Credit
3
- VERSION = "0.2.5"
3
+ VERSION = "0.2.7"
4
4
  end
5
5
  end
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.5
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-08 00:00:00.000000000 Z
10
+ date: 2025-10-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dscf-core