dscf-credit 0.2.4 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0883e69edbfba633fb3b886139de199303da27bfaa0ed9402c39d7ed036fb778'
4
- data.tar.gz: e1ea1f2d623086de0e807c1e986cb340b41d10501056960a1f4c7f8b03b84192
3
+ metadata.gz: 5df5b3471a83e6efbe5e76b6fe3849eddaaae14bf80d833fff1d9cd6acc2b75f
4
+ data.tar.gz: 96f5816a3eece048974871298895351bb925f1ba75043819759a3b3ddbadad8b
5
5
  SHA512:
6
- metadata.gz: f624bce3e341b942f97bc54201f94f995db7dc8a507083c8061d6fdfc58e982f0f2c2f13b573c881b37ffc5e1170184db026c9ae247473a05aef86620620117f
7
- data.tar.gz: ba02250152266b20ecca88cf929b2379b3931fd7d1f0dd9561d632e1928a6c15c7b83deaac2ffa8930601f8bd6de9969b7578a140a08d43aeb272070a95f5138
6
+ metadata.gz: 595f5b367c1fef07dc381b5c0e7932a4a7c65cfd1c8b282b9b0ac6bd25ed05da5b76ad7d037f6f0858a3afd86b7a6da89e4285c4089b4f8be0aab9ec0b9f2bff
7
+ data.tar.gz: fd794ca8544b99ff9277dd5580ea9d36415308f606dca66db785a9b7970500ff52b825c06743c318b05272e9daae2fb70c578f859b5c3b706efc5e9686ddd2bc
@@ -6,7 +6,7 @@ module Dscf::Credit
6
6
 
7
7
  def model_params
8
8
  params.require(:category).permit(
9
- :type,
9
+ :category_type,
10
10
  :name,
11
11
  :description,
12
12
  :document_reference
@@ -68,7 +68,8 @@ module Dscf::Credit
68
68
  :credit_line_id,
69
69
  :credit_limit,
70
70
  :available_limit,
71
- :risk
71
+ :risk,
72
+ :locked
72
73
  )
73
74
  end
74
75
 
@@ -77,7 +78,7 @@ module Dscf::Credit
77
78
  end
78
79
 
79
80
  def allowed_order_columns
80
- %w[id credit_limit available_limit risk created_at updated_at loan_profile_id credit_line_id]
81
+ %w[id credit_limit available_limit risk locked created_at updated_at loan_profile_id credit_line_id]
81
82
  end
82
83
 
83
84
  def default_serializer_includes
@@ -197,7 +197,7 @@ module Dscf::Credit
197
197
  {
198
198
  message: "Automatically rejected based on credit score: #{score}% (<50%)"
199
199
  }
200
- when "pending_review"
200
+ when "pending"
201
201
  {
202
202
  message: "Manual review required based on credit score: #{score}% (50%-60%)"
203
203
  }
@@ -1,18 +1,17 @@
1
1
  module Dscf::Credit
2
2
  class Category < ApplicationRecord
3
3
  self.table_name = "dscf_credit_categories"
4
- self.inheritance_column = nil # Disable STI since 'type' is a business attribute
5
4
 
6
5
  has_many :credit_lines, class_name: "Dscf::Credit::CreditLine", foreign_key: "category_id", dependent: :nullify
7
6
  has_many :scoring_parameters, class_name: "Dscf::Credit::ScoringParameter", foreign_key: "category_id", dependent: :nullify
8
7
 
9
- validates :type, :name, presence: true
10
- validates :name, uniqueness: { scope: :type }
8
+ validates :category_type, :name, presence: true
9
+ validates :name, uniqueness: { scope: :category_type }
11
10
 
12
- scope :by_type, ->(category_type) { where(type: category_type) }
11
+ scope :by_type, ->(category_type) { where(category_type: category_type) }
13
12
 
14
13
  def self.ransackable_attributes(auth_object = nil)
15
- %w[id type name description document_reference created_at updated_at]
14
+ %w[id category_type name description document_reference created_at updated_at]
16
15
  end
17
16
 
18
17
  def self.ransackable_associations(auth_object = nil)
@@ -16,9 +16,11 @@ module Dscf::Credit
16
16
  scope :by_risk_range, ->(min, max) { where(risk: min..max) }
17
17
  scope :by_credit_limit_range, ->(min, max) { where(credit_limit: min..max) }
18
18
  scope :by_available_limit_range, ->(min, max) { where(available_limit: min..max) }
19
+ scope :locked, -> { where(locked: true) }
20
+ scope :unlocked, -> { where(locked: false) }
19
21
 
20
22
  def self.ransackable_attributes(auth_object = nil)
21
- %w[id credit_limit available_limit risk created_at updated_at]
23
+ %w[id credit_limit available_limit risk locked created_at updated_at]
22
24
  end
23
25
 
24
26
  def self.ransackable_associations(auth_object = nil)
@@ -1,6 +1,6 @@
1
1
  module Dscf::Credit
2
2
  class CategorySerializer < ActiveModel::Serializer
3
- attributes :id, :type, :name, :description, :document_reference, :created_at, :updated_at
3
+ attributes :id, :category_type, :name, :description, :document_reference, :created_at, :updated_at
4
4
 
5
5
  has_many :credit_lines, serializer: Dscf::Credit::CreditLineSerializer
6
6
  has_many :scoring_parameters, serializer: Dscf::Credit::ScoringParameterSerializer
@@ -1,6 +1,6 @@
1
1
  module Dscf::Credit
2
2
  class EligibleCreditLineSerializer < ActiveModel::Serializer
3
- attributes :id, :credit_limit, :available_limit, :risk, :created_at, :updated_at
3
+ attributes :id, :credit_limit, :available_limit, :risk, :locked, :created_at, :updated_at
4
4
 
5
5
  belongs_to :loan_profile, serializer: Dscf::Credit::LoanProfileSerializer
6
6
  belongs_to :credit_line, serializer: Dscf::Credit::CreditLineSerializer
@@ -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
@@ -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,7 +1,7 @@
1
1
  class CreateDscfCreditCategories < ActiveRecord::Migration[8.0]
2
2
  def change
3
3
  create_table :dscf_credit_categories do |t|
4
- t.string :type, null: false
4
+ t.string :category_type, null: false
5
5
  t.string :name, null: false
6
6
  t.text :description
7
7
  t.string :document_reference
@@ -9,9 +9,9 @@ class CreateDscfCreditCategories < ActiveRecord::Migration[8.0]
9
9
  t.timestamps
10
10
  end
11
11
 
12
- add_index :dscf_credit_categories, :type
12
+ add_index :dscf_credit_categories, :category_type
13
13
  add_index :dscf_credit_categories, :name
14
14
  add_index :dscf_credit_categories, :document_reference
15
- add_index :dscf_credit_categories, [ :type, :name ], unique: true
15
+ add_index :dscf_credit_categories, [ :category_type, :name ], unique: true
16
16
  end
17
17
  end
@@ -6,6 +6,7 @@ class CreateDscfCreditEligibleCreditLines < ActiveRecord::Migration[8.0]
6
6
  t.decimal :credit_limit, precision: 15, scale: 2, null: false
7
7
  t.decimal :available_limit, precision: 15, scale: 2, null: false
8
8
  t.decimal :risk, precision: 5, scale: 4, null: true
9
+ t.boolean :locked, default: false, null: false
9
10
 
10
11
  t.timestamps
11
12
  end
data/db/seeds.rb CHANGED
@@ -340,24 +340,24 @@ end
340
340
 
341
341
  # 4.5. Categories (independent)
342
342
  puts "Seeding categories..."
343
- sme_category = Dscf::Credit::Category.find_or_create_by(type: 'credit_line', name: 'SME') do |category|
343
+ sme_category = Dscf::Credit::Category.find_or_create_by(category_type: 'credit_line', name: 'SME') do |category|
344
344
  category.description = 'Small and Medium Enterprise credit category'
345
345
  end
346
346
 
347
- personal_category = Dscf::Credit::Category.find_or_create_by(type: 'credit_line', name: 'Personal') do |category|
347
+ personal_category = Dscf::Credit::Category.find_or_create_by(category_type: 'credit_line', name: 'Personal') do |category|
348
348
  category.description = 'Personal credit category for individuals'
349
349
  end
350
350
 
351
- agricultural_category = Dscf::Credit::Category.find_or_create_by(type: 'credit_line', name: 'Agricultural') do |category|
351
+ agricultural_category = Dscf::Credit::Category.find_or_create_by(category_type: 'credit_line', name: 'Agricultural') do |category|
352
352
  category.description = 'Agricultural sector credit category'
353
353
  end
354
354
 
355
- corporate_category = Dscf::Credit::Category.find_or_create_by(type: 'credit_line', name: 'Corporate') do |category|
355
+ corporate_category = Dscf::Credit::Category.find_or_create_by(category_type: 'credit_line', name: 'Corporate') do |category|
356
356
  category.description = 'Corporate credit category for large businesses'
357
357
  end
358
358
 
359
359
  # Add eligibility category for scoring
360
- eligibility_category = Dscf::Credit::Category.find_or_create_by(type: 'eligibility', name: 'default') do |category|
360
+ eligibility_category = Dscf::Credit::Category.find_or_create_by(category_type: 'eligibility', name: 'default') do |category|
361
361
  category.description = 'Default eligibility category for credit scoring'
362
362
  end
363
363
 
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Credit
3
- VERSION = "0.2.4"
3
+ VERSION = "0.2.6"
4
4
  end
5
5
  end
@@ -1,27 +1,27 @@
1
1
  FactoryBot.define do
2
2
  factory :category, class: "Dscf::Credit::Category" do
3
- type { %w[scoring loan_profile payment risk_assessment].sample }
4
- sequence(:name) { |n| "#{type.humanize} Category #{n}" }
3
+ category_type { %w[scoring loan_profile payment risk_assessment].sample }
4
+ sequence(:name) { |n| "#{category_type.humanize} Category #{n}" }
5
5
  description { Faker::Lorem.paragraph }
6
6
  document_reference { Faker::Alphanumeric.alphanumeric(number: 10).upcase }
7
7
 
8
8
  trait :scoring do
9
- type { "scoring" }
9
+ category_type { "scoring" }
10
10
  name { "Scoring Category" }
11
11
  end
12
12
 
13
13
  trait :loan_profile do
14
- type { "loan_profile" }
14
+ category_type { "loan_profile" }
15
15
  name { "Loan Profile Category" }
16
16
  end
17
17
 
18
18
  trait :payment do
19
- type { "payment" }
19
+ category_type { "payment" }
20
20
  name { "Payment Category" }
21
21
  end
22
22
 
23
23
  trait :risk_assessment do
24
- type { "risk_assessment" }
24
+ category_type { "risk_assessment" }
25
25
  name { "Risk Assessment Category" }
26
26
  end
27
27
  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.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adoniyas
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-10-07 00:00:00.000000000 Z
10
+ date: 2025-10-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dscf-core