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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/dscf/core/copilot-instructions.md +683 -0
  3. data/app/controllers/concerns/dscf/core/reviewable_controller.rb +347 -0
  4. data/app/controllers/dscf/credit/categories_controller.rb +3 -3
  5. data/app/controllers/dscf/credit/credit_lines_controller.rb +21 -13
  6. data/app/controllers/dscf/credit/disbursements_controller.rb +15 -16
  7. data/app/controllers/dscf/credit/eligible_credit_lines_controller.rb +50 -8
  8. data/app/controllers/dscf/credit/facilitator_applications_controller.rb +35 -0
  9. data/app/controllers/dscf/credit/facilitators_controller.rb +8 -96
  10. data/app/controllers/dscf/credit/loan_applications_controller.rb +252 -0
  11. data/app/controllers/dscf/credit/loan_profiles_controller.rb +61 -68
  12. data/app/controllers/dscf/credit/loans_controller.rb +7 -7
  13. data/app/controllers/dscf/credit/scoring_parameters_controller.rb +59 -13
  14. data/app/controllers/dscf/credit/system_configs_controller.rb +30 -12
  15. data/app/models/concerns/core/reviewable_model.rb +31 -0
  16. data/app/models/dscf/credit/bank.rb +3 -3
  17. data/app/models/dscf/credit/bank_branch.rb +1 -1
  18. data/app/models/dscf/credit/category.rb +1 -2
  19. data/app/models/dscf/credit/credit_line.rb +4 -10
  20. data/app/models/dscf/credit/eligible_credit_line.rb +2 -2
  21. data/app/models/dscf/credit/facilitator.rb +6 -17
  22. data/app/models/dscf/credit/facilitator_application.rb +20 -0
  23. data/app/models/dscf/credit/loan.rb +4 -4
  24. data/app/models/dscf/credit/loan_application.rb +30 -0
  25. data/app/models/dscf/credit/loan_profile.rb +10 -30
  26. data/app/models/dscf/credit/parameter_normalizer.rb +1 -1
  27. data/app/models/dscf/credit/scoring_parameter.rb +5 -7
  28. data/app/models/dscf/credit/system_config.rb +4 -9
  29. data/app/serializers/dscf/credit/category_serializer.rb +0 -1
  30. data/app/serializers/dscf/credit/credit_line_serializer.rb +2 -2
  31. data/app/serializers/dscf/credit/facilitator_application_serializer.rb +7 -0
  32. data/app/serializers/dscf/credit/facilitator_serializer.rb +3 -6
  33. data/app/serializers/dscf/credit/loan_application_serializer.rb +12 -0
  34. data/app/serializers/dscf/credit/loan_profile_serializer.rb +3 -6
  35. data/app/serializers/dscf/credit/loan_serializer.rb +1 -2
  36. data/app/serializers/dscf/credit/scoring_parameter_serializer.rb +3 -4
  37. data/app/serializers/dscf/credit/system_config_serializer.rb +2 -2
  38. data/app/services/dscf/credit/credit_scoring_engine.rb +258 -0
  39. data/app/services/dscf/credit/disbursement_service.rb +39 -84
  40. data/app/services/dscf/credit/facility_limit_calculation_engine.rb +159 -0
  41. data/app/services/dscf/credit/loan_profile_creation_service.rb +91 -0
  42. data/app/services/dscf/credit/risk_application_service.rb +61 -11
  43. data/config/locales/en.yml +65 -50
  44. data/config/routes.rb +30 -18
  45. data/db/migrate/20250822091131_create_dscf_credit_credit_lines.rb +1 -8
  46. data/db/migrate/20250822091820_create_dscf_credit_system_configs.rb +0 -7
  47. data/db/migrate/20250822092050_create_dscf_credit_scoring_parameters.rb +2 -6
  48. data/db/migrate/20250822092225_create_dscf_credit_parameter_normalizers.rb +1 -1
  49. data/db/migrate/20250822092236_create_dscf_credit_loan_applications.rb +20 -0
  50. data/db/migrate/20250822092246_create_dscf_credit_loan_profiles.rb +7 -19
  51. data/db/migrate/20250822092426_create_dscf_credit_facilitator_applications.rb +10 -0
  52. data/db/migrate/20250822092436_create_dscf_credit_facilitators.rb +1 -16
  53. data/db/migrate/20250822092654_create_dscf_credit_loans.rb +3 -1
  54. data/db/seeds.rb +321 -290
  55. data/lib/dscf/credit/version.rb +1 -1
  56. data/spec/factories/dscf/credit/banks.rb +1 -1
  57. data/spec/factories/dscf/credit/credit_lines.rb +0 -23
  58. data/spec/factories/dscf/credit/facilitator_applications.rb +37 -0
  59. data/spec/factories/dscf/credit/facilitators.rb +8 -30
  60. data/spec/factories/dscf/credit/loan_applications.rb +42 -0
  61. data/spec/factories/dscf/credit/loan_profiles.rb +20 -34
  62. data/spec/factories/dscf/credit/loans.rb +5 -1
  63. data/spec/factories/dscf/credit/parameter_normalizers.rb +4 -4
  64. data/spec/factories/dscf/credit/scoring_parameters.rb +14 -11
  65. data/spec/factories/dscf/credit/system_configs.rb +21 -5
  66. metadata +20 -20
  67. data/app/controllers/concerns/dscf/credit/reviewable.rb +0 -112
  68. data/app/controllers/dscf/credit/payment_requests_controller.rb +0 -87
  69. data/app/controllers/dscf/credit/payments_controller.rb +0 -36
  70. data/app/controllers/dscf/credit/scoring_tables_controller.rb +0 -63
  71. data/app/models/dscf/credit/payment.rb +0 -22
  72. data/app/models/dscf/credit/payment_request.rb +0 -29
  73. data/app/models/dscf/credit/scoring_table.rb +0 -24
  74. data/app/serializers/dscf/credit/payment_request_serializer.rb +0 -10
  75. data/app/serializers/dscf/credit/payment_serializer.rb +0 -8
  76. data/app/serializers/dscf/credit/scoring_table_serializer.rb +0 -9
  77. data/db/migrate/20250822092608_create_dscf_credit_payment_requests.rb +0 -26
  78. data/db/migrate/20250822092843_create_dscf_credit_payments.rb +0 -23
  79. data/db/migrate/20250901172842_create_dscf_credit_scoring_tables.rb +0 -18
  80. data/spec/factories/dscf/credit/payment_requests.rb +0 -40
  81. data/spec/factories/dscf/credit/payments.rb +0 -39
  82. 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, :status, :review_date, :review_feedback, :source, :document_reference, :created_at, :updated_at
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 :scoring_tables, serializer: Dscf::Credit::ScoringTableSerializer
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, :status, :review_date, :review_feedback, :created_at, :updated_at
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
- belongs_to :reviewed_by, serializer: Dscf::Core::UserSerializer
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 :credit_line, :payment_request, :current_user
3
+ attr_reader :amount, :loan_profile, :eligible_credit_line
4
4
 
5
- def initialize(credit_line, payment_request, current_user)
6
- @credit_line = credit_line
7
- @payment_request = payment_request
8
- @current_user = current_user
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 selected credit line
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
- eligible_credit_line = find_eligible_credit_line
19
- return error_result("No eligible credit line found") unless eligible_credit_line
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
- loan_profile = eligible_credit_line.loan_profile
22
- return error_result("Loan profile not approved") unless loan_profile.status == "approved"
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(eligible_credit_line)
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(loan_profile, eligible_credit_line)
29
- process_payment_and_charges(loan)
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 find_eligible_credit_line
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 requested_amount <= 0
45
+ if amount <= 0
53
46
  return error_result("Invalid disbursement amount")
54
47
  end
55
48
 
56
- if requested_amount > available_limit
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(loan_profile, eligible_credit_line)
64
- loan_terms = calculate_loan_terms(eligible_credit_line)
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
- payment_request: payment_request,
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(eligible_credit_line)
87
- principal = payment_request.amount
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
- if credit_line_spec
92
- facilitation_rate = credit_line_spec.facilitation_fee_rate
93
- vat_rate = credit_line_spec.vat
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 process_payment_and_charges(loan)
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