dscf-credit 0.3.1 → 0.3.3
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/dscf/credit/credit_line_specs_controller.rb +7 -6
- data/app/models/dscf/credit/credit_line_spec.rb +4 -2
- data/app/serializers/dscf/credit/credit_line_spec_serializer.rb +2 -1
- data/app/serializers/dscf/credit/eligible_credit_line_serializer.rb +4 -0
- data/app/services/dscf/credit/disbursement_service.rb +5 -4
- data/app/services/dscf/credit/facility_limit_calculation_engine.rb +265 -64
- data/db/migrate/20251011202425_add_base_scoring_parameter_to_dscf_credit_credit_line_specs.rb +5 -0
- data/db/migrate/20251012100039_add_credit_line_divider_to_dscf_credit_credit_line_specs.rb +5 -0
- data/db/migrate/20251012115159_remove_default_from_credit_line_multiplier_in_credit_line_specs.rb +5 -0
- data/lib/dscf/credit/version.rb +1 -1
- data/spec/factories/dscf/credit/credit_line_specs.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2e43f1b7fa03ac6fe72f99412259a4d0345b41cf0487f3d2c3f3ba63e895d589
|
4
|
+
data.tar.gz: af9eb779b069774cbed9fdb356762637ac6ca6f5f61d181485d23e4d64419178
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 87ac35ad44c00672dd4d47ab33fa4f3a27757849b963e7db80aac172844790c08f2af6b45607c1f2526451131fe802fe7df0fbfc985c42c6485ee038df8b5a1c
|
7
|
+
data.tar.gz: a26cc03842c4366d42ae16f6349d6406890da27a1419f4b824e349cf37d8ce4e9e4b0ba1fc540370885bcf93eda99b7b3e5e2e9eb0dc519c907d55d5140857d3
|
@@ -49,12 +49,13 @@ module Dscf::Credit
|
|
49
49
|
:max_interest_calculation_days,
|
50
50
|
:penalty_frequency,
|
51
51
|
:penalty_income_tax,
|
52
|
-
:active
|
52
|
+
:active,
|
53
|
+
:base_scoring_parameter_id
|
53
54
|
)
|
54
55
|
end
|
55
56
|
|
56
57
|
def eager_loaded_associations
|
57
|
-
[ :credit_line, :created_by ]
|
58
|
+
[ :credit_line, :created_by, :base_scoring_parameter ]
|
58
59
|
end
|
59
60
|
|
60
61
|
def allowed_order_columns
|
@@ -63,10 +64,10 @@ module Dscf::Credit
|
|
63
64
|
|
64
65
|
def default_serializer_includes
|
65
66
|
{
|
66
|
-
index: [ :credit_line ],
|
67
|
-
show: [ :credit_line, :created_by ],
|
68
|
-
create: [ :credit_line, :created_by ],
|
69
|
-
update: [ :credit_line, :created_by ]
|
67
|
+
index: [ :credit_line, :base_scoring_parameter ],
|
68
|
+
show: [ :credit_line, :created_by, :base_scoring_parameter ],
|
69
|
+
create: [ :credit_line, :created_by, :base_scoring_parameter ],
|
70
|
+
update: [ :credit_line, :created_by, :base_scoring_parameter ]
|
70
71
|
}
|
71
72
|
end
|
72
73
|
end
|
@@ -4,11 +4,13 @@ module Dscf::Credit
|
|
4
4
|
|
5
5
|
belongs_to :credit_line, class_name: "Dscf::Credit::CreditLine", foreign_key: "credit_line_id"
|
6
6
|
belongs_to :created_by, polymorphic: true
|
7
|
+
belongs_to :base_scoring_parameter, class_name: "Dscf::Credit::ScoringParameter", foreign_key: "base_scoring_parameter_id", optional: true
|
7
8
|
|
8
9
|
validates :min_amount, :max_amount, :interest_rate, :penalty_rate, :facilitation_fee_rate, :tax_rate, :credit_line_multiplier, :max_penalty_days, :loan_duration, :interest_frequency, :interest_income_tax, :vat, :max_interest_calculation_days, :penalty_frequency, :penalty_income_tax, presence: true
|
9
10
|
validates :min_amount, :max_amount, :credit_line_multiplier, numericality: { greater_than: 0 }
|
10
11
|
validates :interest_rate, :penalty_rate, :facilitation_fee_rate, :tax_rate, :interest_income_tax, :vat, :penalty_income_tax, numericality: { greater_than_or_equal_to: 0, less_than: 10 }
|
11
12
|
validates :max_penalty_days, :loan_duration, :max_interest_calculation_days, numericality: { greater_than: 0 }
|
13
|
+
validates :credit_line_divider, numericality: { greater_than: 0, less_than_or_equal_to: 365 }, allow_nil: true
|
12
14
|
validates :interest_frequency, inclusion: { in: %w[daily weekly monthly quarterly annually] }
|
13
15
|
validates :penalty_frequency, inclusion: { in: %w[daily weekly monthly] }
|
14
16
|
validate :max_amount_greater_than_min_amount
|
@@ -19,11 +21,11 @@ module Dscf::Credit
|
|
19
21
|
scope :by_penalty_frequency, ->(frequency) { where(penalty_frequency: frequency) }
|
20
22
|
|
21
23
|
def self.ransackable_attributes(auth_object = nil)
|
22
|
-
%w[id min_amount max_amount interest_rate penalty_rate facilitation_fee_rate tax_rate credit_line_multiplier max_penalty_days loan_duration interest_frequency interest_income_tax vat max_interest_calculation_days penalty_frequency penalty_income_tax active created_at updated_at]
|
24
|
+
%w[id min_amount max_amount interest_rate penalty_rate facilitation_fee_rate tax_rate credit_line_multiplier max_penalty_days loan_duration interest_frequency interest_income_tax vat max_interest_calculation_days penalty_frequency penalty_income_tax credit_line_divider active created_at updated_at]
|
23
25
|
end
|
24
26
|
|
25
27
|
def self.ransackable_associations(auth_object = nil)
|
26
|
-
%w[credit_line created_by]
|
28
|
+
%w[credit_line created_by base_scoring_parameter]
|
27
29
|
end
|
28
30
|
|
29
31
|
private
|
@@ -4,9 +4,10 @@ module Dscf::Credit
|
|
4
4
|
:facilitation_fee_rate, :tax_rate, :credit_line_multiplier, :max_penalty_days,
|
5
5
|
:loan_duration, :interest_frequency, :interest_income_tax,
|
6
6
|
:vat, :max_interest_calculation_days, :penalty_frequency,
|
7
|
-
:penalty_income_tax, :active, :created_at, :updated_at
|
7
|
+
:penalty_income_tax, :credit_line_divider, :active, :created_at, :updated_at
|
8
8
|
|
9
9
|
belongs_to :credit_line, serializer: Dscf::Credit::CreditLineSerializer
|
10
10
|
belongs_to :created_by, serializer: Dscf::Core::UserSerializer
|
11
|
+
belongs_to :base_scoring_parameter, serializer: Dscf::Credit::ScoringParameterSerializer
|
11
12
|
end
|
12
13
|
end
|
@@ -4,5 +4,9 @@ module Dscf::Credit
|
|
4
4
|
|
5
5
|
belongs_to :loan_profile, serializer: Dscf::Credit::LoanProfileSerializer
|
6
6
|
belongs_to :credit_line, serializer: Dscf::Credit::CreditLineSerializer
|
7
|
+
|
8
|
+
attribute :user_id do
|
9
|
+
object.loan_profile.loan_application.user_id if object.loan_profile && object.loan_profile.loan_application
|
10
|
+
end
|
7
11
|
end
|
8
12
|
end
|
@@ -24,7 +24,7 @@ module Dscf::Credit
|
|
24
24
|
loan_profile_status = loan_profile.current_status_for(:default)
|
25
25
|
return error_result("Loan profile not approved") unless loan_profile_status == "approved"
|
26
26
|
|
27
|
-
validation_result = validate_disbursement_amount
|
27
|
+
validation_result = validate_disbursement_amount(credit_line)
|
28
28
|
return validation_result unless validation_result[:success]
|
29
29
|
|
30
30
|
ActiveRecord::Base.transaction do
|
@@ -49,11 +49,12 @@ module Dscf::Credit
|
|
49
49
|
|
50
50
|
private
|
51
51
|
|
52
|
-
def validate_disbursement_amount
|
52
|
+
def validate_disbursement_amount(credit_line)
|
53
53
|
available_limit = eligible_credit_line.available_limit
|
54
|
+
credit_line_spec = credit_line.credit_line_specs.active.first
|
54
55
|
|
55
|
-
if amount
|
56
|
-
return error_result("
|
56
|
+
if credit_line_spec && amount < credit_line_spec.min_amount
|
57
|
+
return error_result("Amount must be at least #{credit_line_spec.min_amount}")
|
57
58
|
end
|
58
59
|
|
59
60
|
if amount > available_limit
|
@@ -1,7 +1,38 @@
|
|
1
1
|
module Dscf::Credit
|
2
|
+
# FacilityLimitCalculationEngine calculates credit facility limits for loan profiles
|
3
|
+
# based on dynamic scoring parameters and credit line specifications.
|
4
|
+
#
|
5
|
+
# @example Basic usage
|
6
|
+
# loan_profile = Dscf::Credit::LoanProfile.find(profile_id)
|
7
|
+
# category = Dscf::Credit::Category.find(category_id)
|
8
|
+
# engine = Dscf::Credit::FacilityLimitCalculationEngine.new(loan_profile.id, category.id)
|
9
|
+
# result = engine.calculate_facility_limits
|
10
|
+
#
|
11
|
+
# if result[:success]
|
12
|
+
# puts "Limits calculated: #{result[:message]}"
|
13
|
+
# puts "Eligible credit lines: #{result[:data].count}"
|
14
|
+
# else
|
15
|
+
# puts "Error: #{result[:error]}"
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# @example Result structure
|
19
|
+
# {
|
20
|
+
# success: true,
|
21
|
+
# data: [#<EligibleCreditLine>, ...],
|
22
|
+
# message: "Facility limits calculated successfully"
|
23
|
+
# }
|
24
|
+
#
|
25
|
+
# @see docs/CREDIT_SCORING_ARCHITECTURE.md for scoring architecture
|
2
26
|
class FacilityLimitCalculationEngine
|
3
27
|
attr_reader :loan_profile, :category_id, :errors
|
4
28
|
|
29
|
+
# Initialize the facility limit calculation engine
|
30
|
+
#
|
31
|
+
# @param loan_profile_id [Integer] The ID of the loan profile to calculate limits for
|
32
|
+
# @param category_id [Integer] The ID of the credit line category
|
33
|
+
#
|
34
|
+
# @example
|
35
|
+
# engine = FacilityLimitCalculationEngine.new(123, 456)
|
5
36
|
def initialize(loan_profile_id, category_id)
|
6
37
|
@errors = []
|
7
38
|
@loan_profile = LoanProfile.find(loan_profile_id)
|
@@ -10,42 +41,75 @@ module Dscf::Credit
|
|
10
41
|
@errors << "Loan profile not found: #{e.message}"
|
11
42
|
end
|
12
43
|
|
13
|
-
# Calculate facility limits for all credit lines
|
44
|
+
# Calculate facility limits for all eligible credit lines
|
45
|
+
#
|
46
|
+
# Process steps:
|
47
|
+
# 1. Retrieve active credit lines for the category and bank
|
48
|
+
# 2. For each credit line, get the latest active credit line spec
|
49
|
+
# 3. Calculate base metric from scoring parameter (dynamic based on parameter type)
|
50
|
+
# 4. Calculate credit limit using: base_metric × multiplier × score_percentage
|
51
|
+
# 5. Apply min/max constraints from credit line spec
|
52
|
+
# 6. Create eligible credit line records with calculated limits
|
53
|
+
# 7. Update loan profile's total_limit
|
54
|
+
#
|
55
|
+
# @return [Hash] Result hash with the following structure:
|
56
|
+
# - :success [Boolean] Whether the operation succeeded
|
57
|
+
# - :data [Array<EligibleCreditLine>] Created eligible credit lines (empty array on failure)
|
58
|
+
# - :message [String] Success message
|
59
|
+
# - :error [String] Error message (only present on failure)
|
60
|
+
# - :errors [Array<String>] Detailed error messages for debugging
|
61
|
+
#
|
62
|
+
# @example Success response
|
63
|
+
# {
|
64
|
+
# success: true,
|
65
|
+
# data: [#<EligibleCreditLine id: 1>, #<EligibleCreditLine id: 2>],
|
66
|
+
# message: "Facility limits calculated successfully"
|
67
|
+
# }
|
68
|
+
#
|
69
|
+
# @example Error response
|
70
|
+
# {
|
71
|
+
# success: false,
|
72
|
+
# error: "No credit lines found for category 5 and bank 10",
|
73
|
+
# errors: ["No credit lines found for category 5 and bank 10"]
|
74
|
+
# }
|
14
75
|
def calculate_facility_limits
|
15
76
|
return error_result("Loan profile is required") unless loan_profile
|
16
77
|
return error_result("Category ID is required") unless category_id
|
17
78
|
|
18
79
|
begin
|
19
80
|
ActiveRecord::Base.transaction do
|
20
|
-
# Get all credit lines for the category and bank
|
21
81
|
credit_lines = get_credit_lines_for_calculation
|
22
|
-
|
23
82
|
if credit_lines.empty?
|
24
83
|
return error_result("No credit lines found for category #{category_id} and bank #{loan_profile.loan_application.bank_id}")
|
25
84
|
end
|
26
85
|
|
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
86
|
eligible_credit_lines = []
|
36
|
-
|
37
87
|
credit_lines.each do |credit_line|
|
38
|
-
|
88
|
+
credit_line_spec = credit_line.credit_line_specs.active.order(created_at: :desc).first
|
89
|
+
unless credit_line_spec
|
90
|
+
@errors << "No active credit line spec for credit line #{credit_line.id}"
|
91
|
+
next
|
92
|
+
end
|
93
|
+
|
94
|
+
base_metric = calculate_base_metric(credit_line_spec)
|
95
|
+
if base_metric <= 0
|
96
|
+
@errors << "Invalid base metric for credit line #{credit_line.id}: #{base_metric}"
|
97
|
+
next
|
98
|
+
end
|
39
99
|
|
40
|
-
|
100
|
+
limit_result = calculate_credit_line_limit(credit_line, base_metric, credit_line_spec)
|
101
|
+
unless limit_result[:success]
|
102
|
+
@errors << limit_result[:error]
|
103
|
+
next
|
104
|
+
end
|
105
|
+
|
106
|
+
if limit_result[:limit] >= 0
|
41
107
|
eligible_credit_line = create_eligible_credit_line(credit_line, limit_result[:limit])
|
42
108
|
eligible_credit_lines << eligible_credit_line
|
43
109
|
end
|
44
110
|
end
|
45
111
|
|
46
|
-
# Update loan profile total limit
|
47
112
|
update_loan_profile_total_limit
|
48
|
-
|
49
113
|
success_result(eligible_credit_lines, "Facility limits calculated successfully")
|
50
114
|
end
|
51
115
|
rescue StandardError => e
|
@@ -57,7 +121,17 @@ module Dscf::Credit
|
|
57
121
|
|
58
122
|
private
|
59
123
|
|
60
|
-
#
|
124
|
+
# Retrieve credit lines eligible for calculation
|
125
|
+
#
|
126
|
+
# Filters credit lines by:
|
127
|
+
# - Category ID
|
128
|
+
# - Bank ID from loan application
|
129
|
+
# - Active credit line specs
|
130
|
+
#
|
131
|
+
# Includes:
|
132
|
+
# - Credit line specs with base_scoring_parameter association
|
133
|
+
#
|
134
|
+
# @return [ActiveRecord::Relation<CreditLine>] Filtered credit lines
|
61
135
|
def get_credit_lines_for_calculation
|
62
136
|
CreditLine.joins(:category, :credit_line_specs)
|
63
137
|
.where(
|
@@ -65,66 +139,186 @@ module Dscf::Credit
|
|
65
139
|
bank_id: loan_profile.loan_application.bank_id,
|
66
140
|
dscf_credit_credit_line_specs: { active: true }
|
67
141
|
)
|
68
|
-
.includes(:
|
69
|
-
.distinct
|
142
|
+
.includes(credit_line_specs: :base_scoring_parameter)
|
70
143
|
end
|
71
144
|
|
72
|
-
# Calculate base metric
|
73
|
-
|
74
|
-
|
145
|
+
# Calculate the base metric for credit limit calculation
|
146
|
+
#
|
147
|
+
# This method dynamically determines the base metric value based on the
|
148
|
+
# base_scoring_parameter associated with the credit line spec. The base metric
|
149
|
+
# is extracted from the loan application's facilitator_info and normalized
|
150
|
+
# using the credit_line_divider from the credit line spec.
|
151
|
+
#
|
152
|
+
# Flow:
|
153
|
+
# 1. Check if credit_line_spec has a base_scoring_parameter
|
154
|
+
# 2. If not, use fallback_base_metric (looks for avg_monthly_purchase manually)
|
155
|
+
# 3. Extract the raw_value from facilitator_info using parameter ID
|
156
|
+
# 4. Normalize the value using credit_line_divider:
|
157
|
+
# - If divider is present and > 0: divide raw_value by divider
|
158
|
+
# - If divider is nil or <= 0: use raw_value as-is
|
159
|
+
#
|
160
|
+
# @param credit_line_spec [CreditLineSpec] The credit line specification
|
161
|
+
# @return [Float] The calculated base metric (0.0 if invalid or missing data)
|
162
|
+
#
|
163
|
+
# @example With divider = 30
|
164
|
+
# # credit_line_spec.credit_line_divider = 30
|
165
|
+
# # facilitator_info[param_id]["raw_value"] = 30000.0
|
166
|
+
# # Returns: 1000.0 (30000 / 30)
|
167
|
+
#
|
168
|
+
# @example With divider = 365
|
169
|
+
# # credit_line_spec.credit_line_divider = 365
|
170
|
+
# # facilitator_info[param_id]["raw_value"] = 365000.0
|
171
|
+
# # Returns: 1000.0 (365000 / 365)
|
172
|
+
#
|
173
|
+
# @example With nil divider
|
174
|
+
# # credit_line_spec.credit_line_divider = nil
|
175
|
+
# # facilitator_info[param_id]["raw_value"] = 1000.0
|
176
|
+
# # Returns: 1000.0 (no normalization)
|
177
|
+
def calculate_base_metric(credit_line_spec)
|
178
|
+
return fallback_base_metric unless credit_line_spec.base_scoring_parameter
|
75
179
|
|
76
|
-
|
77
|
-
|
78
|
-
|
180
|
+
base_param = credit_line_spec.base_scoring_parameter
|
181
|
+
facilitator_info = loan_profile.loan_application.facilitator_info || {}
|
182
|
+
param_id = base_param.id.to_s
|
79
183
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
break
|
85
|
-
end
|
184
|
+
param_data = facilitator_info[param_id]
|
185
|
+
unless param_data
|
186
|
+
Rails.logger.warn "No data for scoring parameter #{base_param.id} (#{base_param.name}) in facilitator_info"
|
187
|
+
return 0.0
|
86
188
|
end
|
87
189
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
190
|
+
raw_value = param_data["raw_value"] || param_data[:raw_value]
|
191
|
+
value = raw_value.to_f rescue 0.0
|
192
|
+
if value <= 0
|
193
|
+
Rails.logger.warn "Invalid value #{raw_value} for scoring parameter #{base_param.id} (#{base_param.name})"
|
194
|
+
return 0.0
|
93
195
|
end
|
94
196
|
|
95
|
-
|
96
|
-
|
97
|
-
avg_monthly_purchase / 30.0
|
197
|
+
normalize_value(value, credit_line_spec)
|
98
198
|
end
|
99
199
|
|
100
|
-
#
|
101
|
-
|
102
|
-
|
200
|
+
# Fallback method to calculate base metric when base_scoring_parameter is not set
|
201
|
+
#
|
202
|
+
# This method searches facilitator_info for a parameter named "avg_monthly_purchase"
|
203
|
+
# and uses it as the base metric, normalized to a daily value.
|
204
|
+
#
|
205
|
+
# This provides backward compatibility for credit line specs that don't have
|
206
|
+
# a base_scoring_parameter association yet.
|
207
|
+
#
|
208
|
+
# @return [Float] The calculated base metric (0.0 if not found or invalid)
|
209
|
+
#
|
210
|
+
# @example Found avg_monthly_purchase
|
211
|
+
# # facilitator_info = { "123" => { "param_name" => "avg_monthly_purchase", "raw_value" => 30000.0 } }
|
212
|
+
# # Returns: 1000.0 (30000 / 30)
|
213
|
+
#
|
214
|
+
# @example Not found
|
215
|
+
# # facilitator_info = { "123" => { "param_name" => "other_param", "raw_value" => 5000.0 } }
|
216
|
+
# # Returns: 0.0
|
217
|
+
def fallback_base_metric
|
218
|
+
facilitator_info = loan_profile.loan_application.facilitator_info || {}
|
219
|
+
param_value = nil
|
103
220
|
|
104
|
-
|
105
|
-
|
221
|
+
facilitator_info.each do |param_id, param_data|
|
222
|
+
next unless param_data.is_a?(Hash)
|
223
|
+
if param_data["param_name"] == "avg_monthly_purchase" || param_data[:param_name] == "avg_monthly_purchase"
|
224
|
+
param_value = param_data["raw_value"] || param_data[:raw_value]
|
225
|
+
break
|
226
|
+
end
|
106
227
|
end
|
107
228
|
|
229
|
+
value = param_value.to_f rescue 0.0
|
230
|
+
return 0.0 if value <= 0
|
231
|
+
value / 30.0
|
232
|
+
end
|
233
|
+
|
234
|
+
# Normalize a parameter value based on the credit line spec's divider
|
235
|
+
#
|
236
|
+
# Divides the raw parameter value by the credit_line_divider from the credit line spec.
|
237
|
+
# If the divider is nil or <= 0, returns the raw value without normalization.
|
238
|
+
#
|
239
|
+
# @param value [Float] The raw parameter value to normalize
|
240
|
+
# @param credit_line_spec [CreditLineSpec] The credit line specification with divider
|
241
|
+
# @return [Float] The normalized value (or raw value if divider is invalid)
|
242
|
+
#
|
243
|
+
# @example With valid divider
|
244
|
+
# # credit_line_spec.credit_line_divider = 30
|
245
|
+
# # normalize_value(30000.0, credit_line_spec) → 1000.0
|
246
|
+
#
|
247
|
+
# @example With nil divider
|
248
|
+
# # credit_line_spec.credit_line_divider = nil
|
249
|
+
# # normalize_value(30000.0, credit_line_spec) → 30000.0
|
250
|
+
#
|
251
|
+
# @example With zero divider
|
252
|
+
# # credit_line_spec.credit_line_divider = 0
|
253
|
+
# # normalize_value(30000.0, credit_line_spec) → 30000.0
|
254
|
+
def normalize_value(value, credit_line_spec)
|
255
|
+
divider = credit_line_spec.credit_line_divider
|
256
|
+
return value if divider.nil? || divider <= 0 # No normalization if divider is NULL or invalid
|
257
|
+
value / divider
|
258
|
+
end
|
259
|
+
|
260
|
+
# Calculate the credit limit for a specific credit line
|
261
|
+
#
|
262
|
+
# Uses the formula:
|
263
|
+
# credit_limit = base_metric × credit_line_multiplier × score_percentage
|
264
|
+
#
|
265
|
+
# Then applies min/max constraints from the credit line spec.
|
266
|
+
#
|
267
|
+
# @param credit_line [CreditLine] The credit line being calculated
|
268
|
+
# @param base_metric [Float] The normalized base metric value
|
269
|
+
# @param credit_line_spec [CreditLineSpec] The credit line specification with multiplier and constraints
|
270
|
+
# @return [Hash] Result with :success, :limit, and :credit_line_spec
|
271
|
+
#
|
272
|
+
# @example Successful calculation
|
273
|
+
# # base_metric = 1000.0
|
274
|
+
# # credit_line_spec.credit_line_multiplier = 30.0
|
275
|
+
# # loan_profile.score = 75.0
|
276
|
+
# # calculated_limit = 1000 × 30 × 0.75 = 22500.0
|
277
|
+
# # { success: true, limit: 22500.0, credit_line_spec: spec }
|
278
|
+
def calculate_credit_line_limit(credit_line, base_metric, credit_line_spec)
|
108
279
|
score_percentage = loan_profile.score / 100.0
|
109
280
|
calculated_limit = base_metric * credit_line_spec.credit_line_multiplier * score_percentage
|
110
|
-
|
111
281
|
final_limit = apply_min_max_constraints(calculated_limit, credit_line_spec)
|
112
|
-
|
113
282
|
{ success: true, limit: final_limit, credit_line_spec: credit_line_spec }
|
114
283
|
end
|
115
284
|
|
285
|
+
# Apply minimum and maximum constraints to a calculated credit limit
|
286
|
+
#
|
287
|
+
# Constraint rules:
|
288
|
+
# - If calculated_limit > max_amount → cap at max_amount
|
289
|
+
# - If calculated_limit < min_amount → set to 0.0 (ineligible)
|
290
|
+
# - Otherwise → use calculated_limit
|
291
|
+
#
|
292
|
+
# @param calculated_limit [Float] The initially calculated credit limit
|
293
|
+
# @param credit_line_spec [CreditLineSpec] The spec with min_amount and max_amount
|
294
|
+
# @return [Float] The constrained credit limit
|
295
|
+
#
|
296
|
+
# @example Within constraints
|
297
|
+
# # calculated_limit = 22500.0, min = 5000, max = 50000
|
298
|
+
# # Returns: 22500.0
|
299
|
+
#
|
300
|
+
# @example Exceeds maximum
|
301
|
+
# # calculated_limit = 60000.0, min = 5000, max = 50000
|
302
|
+
# # Returns: 50000.0 (capped)
|
303
|
+
#
|
304
|
+
# @example Below minimum
|
305
|
+
# # calculated_limit = 3000.0, min = 5000, max = 50000
|
306
|
+
# # Returns: 0.0 (ineligible)
|
116
307
|
def apply_min_max_constraints(calculated_limit, credit_line_spec)
|
117
|
-
if calculated_limit > credit_line_spec.max_amount
|
118
|
-
|
119
|
-
end
|
120
|
-
|
121
|
-
if calculated_limit < credit_line_spec.min_amount
|
122
|
-
return 0.0
|
123
|
-
end
|
124
|
-
|
308
|
+
return credit_line_spec.max_amount if calculated_limit > credit_line_spec.max_amount
|
309
|
+
return 0.0 if calculated_limit < credit_line_spec.min_amount
|
125
310
|
calculated_limit
|
126
311
|
end
|
127
312
|
|
313
|
+
# Create an eligible credit line record with the calculated limit
|
314
|
+
#
|
315
|
+
# Creates a new EligibleCreditLine associated with the loan profile,
|
316
|
+
# setting both credit_limit and available_limit to the calculated amount.
|
317
|
+
# Risk is initially set to nil.
|
318
|
+
#
|
319
|
+
# @param credit_line [CreditLine] The credit line to create eligibility for
|
320
|
+
# @param limit [Float] The calculated credit limit
|
321
|
+
# @return [EligibleCreditLine] The created eligible credit line record
|
128
322
|
def create_eligible_credit_line(credit_line, limit)
|
129
323
|
loan_profile.eligible_credit_lines.create!(
|
130
324
|
credit_line: credit_line,
|
@@ -134,26 +328,33 @@ module Dscf::Credit
|
|
134
328
|
)
|
135
329
|
end
|
136
330
|
|
331
|
+
# Update the loan profile's total limit
|
332
|
+
#
|
333
|
+
# Sums all available_limit values from the loan profile's eligible credit lines
|
334
|
+
# and updates the loan_profile.total_limit to reflect the current state.
|
335
|
+
#
|
336
|
+
# @return [void]
|
137
337
|
def update_loan_profile_total_limit
|
138
338
|
total_limit = loan_profile.eligible_credit_lines.sum(:available_limit)
|
139
339
|
loan_profile.update!(total_limit: total_limit)
|
140
340
|
end
|
141
341
|
|
342
|
+
# Build success result hash
|
343
|
+
#
|
344
|
+
# @param data [Array<EligibleCreditLine>] The created eligible credit lines
|
345
|
+
# @param message [String] Success message describing the result
|
346
|
+
# @return [Hash] Success response with data and message
|
142
347
|
def success_result(data, message)
|
143
|
-
{
|
144
|
-
success: true,
|
145
|
-
data: data,
|
146
|
-
message: message
|
147
|
-
}
|
348
|
+
{ success: true, data: data, message: message }
|
148
349
|
end
|
149
350
|
|
351
|
+
# Build error result hash
|
352
|
+
#
|
353
|
+
# @param error_message [String] Error message describing what went wrong
|
354
|
+
# @return [Hash] Error response with success: false, error message, and detailed errors array
|
150
355
|
def error_result(error_message)
|
151
356
|
@errors << error_message unless @errors.include?(error_message)
|
152
|
-
{
|
153
|
-
success: false,
|
154
|
-
error: error_message,
|
155
|
-
errors: @errors
|
156
|
-
}
|
357
|
+
{ success: false, error: error_message, errors: @errors }
|
157
358
|
end
|
158
359
|
end
|
159
360
|
end
|
data/lib/dscf/credit/version.rb
CHANGED
@@ -2,6 +2,7 @@ FactoryBot.define do
|
|
2
2
|
factory :credit_line_spec, class: "Dscf::Credit::CreditLineSpec" do
|
3
3
|
association :credit_line, factory: :credit_line
|
4
4
|
association :created_by, factory: :user if defined?(Dscf::Core::User)
|
5
|
+
base_scoring_parameter { nil }
|
5
6
|
min_amount { Faker::Number.decimal(l_digits: 4, r_digits: 2).to_f }
|
6
7
|
max_amount { min_amount + Faker::Number.decimal(l_digits: 5, r_digits: 2).to_f }
|
7
8
|
interest_rate { Faker::Number.decimal(l_digits: 1, r_digits: 4).to_f }
|
@@ -9,6 +10,7 @@ FactoryBot.define do
|
|
9
10
|
facilitation_fee_rate { Faker::Number.decimal(l_digits: 1, r_digits: 4).to_f }
|
10
11
|
tax_rate { Faker::Number.decimal(l_digits: 1, r_digits: 4).to_f }
|
11
12
|
credit_line_multiplier { Faker::Number.between(from: 15, to: 60) }
|
13
|
+
credit_line_divider { [ nil, Faker::Number.between(from: 1, to: 365) ].sample }
|
12
14
|
max_penalty_days { Faker::Number.between(from: 30, to: 90) }
|
13
15
|
loan_duration { Faker::Number.between(from: 30, to: 365) }
|
14
16
|
interest_frequency { %w[daily weekly monthly quarterly annually].sample }
|
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.3.
|
4
|
+
version: 0.3.3
|
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-12 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: dscf-core
|
@@ -446,6 +446,9 @@ files:
|
|
446
446
|
- db/migrate/20250825231109_create_dscf_credit_bank_staff.rb
|
447
447
|
- db/migrate/20250917120000_create_dscf_credit_eligible_credit_lines.rb
|
448
448
|
- db/migrate/20251003132939_create_dscf_credit_loan_accruals.rb
|
449
|
+
- db/migrate/20251011202425_add_base_scoring_parameter_to_dscf_credit_credit_line_specs.rb
|
450
|
+
- db/migrate/20251012100039_add_credit_line_divider_to_dscf_credit_credit_line_specs.rb
|
451
|
+
- db/migrate/20251012115159_remove_default_from_credit_line_multiplier_in_credit_line_specs.rb
|
449
452
|
- db/seeds.rb
|
450
453
|
- lib/dscf/credit.rb
|
451
454
|
- lib/dscf/credit/engine.rb
|