dscf-credit 0.1.6 → 0.1.8
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/facilitator_applications_controller.rb +88 -5
- data/app/controllers/dscf/credit/loan_accruals_controller.rb +113 -0
- data/app/controllers/dscf/credit/loan_applications_controller.rb +5 -5
- data/app/controllers/dscf/credit/loan_profiles_controller.rb +2 -2
- data/app/controllers/dscf/credit/loans_controller.rb +2 -6
- data/app/controllers/dscf/credit/repayments_controller.rb +3 -3
- data/app/controllers/dscf/credit/scoring_parameters_controller.rb +9 -13
- data/app/controllers/dscf/credit/users_controller.rb +6 -0
- data/app/jobs/dscf/credit/generate_daily_accruals_job.rb +78 -0
- data/app/models/dscf/credit/facilitator_application.rb +0 -1
- data/app/models/dscf/credit/loan.rb +26 -5
- data/app/models/dscf/credit/loan_accrual.rb +25 -0
- data/app/serializers/dscf/credit/loan_accrual_serializer.rb +7 -0
- data/app/serializers/dscf/credit/loan_serializer.rb +24 -2
- data/app/services/dscf/credit/credit_scoring_engine.rb +8 -8
- data/app/services/dscf/credit/disbursement_service.rb +50 -8
- data/app/services/dscf/credit/loan_accrual_generator_service.rb +272 -0
- data/app/services/dscf/credit/repayment_service.rb +216 -87
- data/config/locales/en.yml +36 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20250822092426_create_dscf_credit_facilitator_applications.rb +1 -1
- data/db/migrate/20250822092654_create_dscf_credit_loans.rb +0 -4
- data/db/migrate/20251003132939_create_dscf_credit_loan_accruals.rb +19 -0
- data/db/seeds.rb +20 -5
- data/lib/dscf/credit/version.rb +1 -1
- data/spec/factories/dscf/credit/loan_accruals.rb +29 -0
- data/spec/factories/dscf/credit/loans.rb +0 -4
- metadata +9 -2
@@ -60,11 +60,7 @@ module Dscf::Credit
|
|
60
60
|
loan_profile: loan_profile,
|
61
61
|
credit_line: credit_line,
|
62
62
|
principal_amount: amount,
|
63
|
-
|
64
|
-
total_loan_amount: loan_terms[:total_amount],
|
65
|
-
remaining_amount: loan_terms[:total_amount],
|
66
|
-
accrued_interest: 0,
|
67
|
-
accrued_penalty: 0,
|
63
|
+
remaining_amount: amount, # Only principal amount, not total
|
68
64
|
status: "disbursed",
|
69
65
|
due_date: loan_terms[:due_date],
|
70
66
|
disbursed_at: Time.current,
|
@@ -73,6 +69,12 @@ module Dscf::Credit
|
|
73
69
|
|
74
70
|
raise "Failed to create loan: #{loan.errors.full_messages.join(', ')}" unless loan.save
|
75
71
|
|
72
|
+
# Create facilitator fee accrual
|
73
|
+
create_facilitator_fee_accrual(loan, loan_terms[:facilitation_fee])
|
74
|
+
|
75
|
+
# Create VAT accrual if applicable
|
76
|
+
create_vat_accrual(loan, loan_terms[:vat_amount]) if loan_terms[:vat_amount] > 0
|
77
|
+
|
76
78
|
loan
|
77
79
|
end
|
78
80
|
|
@@ -104,19 +106,59 @@ module Dscf::Credit
|
|
104
106
|
}
|
105
107
|
end
|
106
108
|
|
109
|
+
def create_facilitator_fee_accrual(loan, facilitation_fee)
|
110
|
+
return if facilitation_fee <= 0
|
111
|
+
|
112
|
+
Dscf::Credit::LoanAccrual.create!(
|
113
|
+
loan: loan,
|
114
|
+
accrual_type: "facilitation_fee",
|
115
|
+
amount: facilitation_fee,
|
116
|
+
applied_on: Date.current,
|
117
|
+
status: "pending"
|
118
|
+
)
|
119
|
+
end
|
120
|
+
|
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
|
+
|
107
133
|
def update_credit_line_limits(loan)
|
108
|
-
|
134
|
+
# Calculate total amount from accruals: principal + facilitation_fee + vat
|
135
|
+
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
|
138
|
+
|
139
|
+
new_available_limit = eligible_credit_line.available_limit - total_amount
|
109
140
|
eligible_credit_line.update!(available_limit: [ new_available_limit, 0 ].max)
|
110
141
|
end
|
111
142
|
|
112
143
|
def success_result(loan)
|
144
|
+
# Reload to get associated accruals
|
145
|
+
loan.reload
|
146
|
+
|
147
|
+
facilitation_fee_accrual = loan.loan_accruals.find_by(accrual_type: "facilitation_fee")
|
148
|
+
vat_accrual = loan.loan_accruals.find_by(accrual_type: "tax")
|
149
|
+
|
150
|
+
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
|
153
|
+
|
113
154
|
{
|
114
155
|
success: true,
|
115
156
|
loan: loan,
|
116
157
|
disbursement_details: {
|
117
158
|
principal_amount: loan.principal_amount.to_f,
|
118
|
-
facilitation_fee:
|
119
|
-
|
159
|
+
facilitation_fee: facilitation_fee,
|
160
|
+
vat_amount: vat_amount,
|
161
|
+
total_loan_amount: total_amount,
|
120
162
|
due_date: loan.due_date,
|
121
163
|
disbursed_at: loan.disbursed_at
|
122
164
|
},
|
@@ -0,0 +1,272 @@
|
|
1
|
+
module Dscf::Credit
|
2
|
+
# LoanAccrualGeneratorService generates daily interest and penalty accruals for active loans.
|
3
|
+
# This service is designed to be run daily by a background job/cron job.
|
4
|
+
#
|
5
|
+
# @example Run for all active loans
|
6
|
+
# service = Dscf::Credit::LoanAccrualGeneratorService.new
|
7
|
+
# result = service.generate_daily_accruals
|
8
|
+
#
|
9
|
+
# @example Run for specific loans
|
10
|
+
# loan_ids = [1, 2, 3]
|
11
|
+
# service = Dscf::Credit::LoanAccrualGeneratorService.new(loan_ids: loan_ids)
|
12
|
+
# result = service.generate_daily_accruals
|
13
|
+
#
|
14
|
+
# @example Run for specific date (useful for backfilling)
|
15
|
+
# service = Dscf::Credit::LoanAccrualGeneratorService.new(accrual_date: Date.yesterday)
|
16
|
+
# result = service.generate_daily_accruals
|
17
|
+
#
|
18
|
+
# @see docs/LOAN_ACCRUAL_GENERATOR_DOCUMENTATION.md for detailed documentation
|
19
|
+
class LoanAccrualGeneratorService
|
20
|
+
attr_reader :loan_ids, :accrual_date, :force_regenerate
|
21
|
+
|
22
|
+
# Initialize the loan accrual generator service
|
23
|
+
#
|
24
|
+
# @param loan_ids [Array<Integer>, nil] Optional array of specific loan IDs to process.
|
25
|
+
# If nil, processes all eligible loans.
|
26
|
+
# @param accrual_date [Date] The date for which to generate accruals. Defaults to today.
|
27
|
+
# @param force_regenerate [Boolean] If true, regenerates accruals even if they already exist
|
28
|
+
# for the given date. Defaults to false.
|
29
|
+
def initialize(loan_ids: nil, accrual_date: Date.current, force_regenerate: false)
|
30
|
+
@loan_ids = loan_ids
|
31
|
+
@accrual_date = accrual_date
|
32
|
+
@force_regenerate = force_regenerate
|
33
|
+
end
|
34
|
+
|
35
|
+
# Generate daily accruals for eligible loans
|
36
|
+
#
|
37
|
+
# Eligible loans are those with:
|
38
|
+
# - Status: active, overdue, or disbursed
|
39
|
+
# - Have outstanding balance (remaining_amount > 0 or pending accruals exist)
|
40
|
+
#
|
41
|
+
# For each eligible loan:
|
42
|
+
# 1. Calculate daily interest based on principal and interest rate
|
43
|
+
# 2. Calculate penalty if loan is overdue
|
44
|
+
# 3. Create accrual records with status='pending'
|
45
|
+
#
|
46
|
+
# @return [Hash] Result hash with the following structure:
|
47
|
+
# - :success [Boolean] Whether the operation succeeded
|
48
|
+
# - :accruals_created [Integer] Number of accruals created
|
49
|
+
# - :loans_processed [Integer] Number of loans processed
|
50
|
+
# - :accrual_details [Array<Hash>] Details of each accrual created
|
51
|
+
# - :errors [Array<Hash>] Any errors encountered (loan_id, error message)
|
52
|
+
# - :message [String] Summary message
|
53
|
+
#
|
54
|
+
# @example Success response
|
55
|
+
# {
|
56
|
+
# success: true,
|
57
|
+
# accruals_created: 45,
|
58
|
+
# loans_processed: 30,
|
59
|
+
# accrual_details: [
|
60
|
+
# { loan_id: 1, accrual_type: 'interest', amount: 50.00 },
|
61
|
+
# { loan_id: 1, accrual_type: 'penalty', amount: 25.00 },
|
62
|
+
# ...
|
63
|
+
# ],
|
64
|
+
# errors: [],
|
65
|
+
# message: "Successfully generated 45 accruals for 30 loans on 2025-10-03"
|
66
|
+
# }
|
67
|
+
def generate_daily_accruals
|
68
|
+
loans = fetch_eligible_loans
|
69
|
+
|
70
|
+
return success_result([], []) if loans.empty?
|
71
|
+
|
72
|
+
accrual_details = []
|
73
|
+
errors = []
|
74
|
+
|
75
|
+
loans.each do |loan|
|
76
|
+
begin
|
77
|
+
loan_accruals = generate_accruals_for_loan(loan)
|
78
|
+
accrual_details.concat(loan_accruals)
|
79
|
+
rescue StandardError => e
|
80
|
+
errors << {
|
81
|
+
loan_id: loan.id,
|
82
|
+
error: e.message
|
83
|
+
}
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
success_result(accrual_details, errors)
|
88
|
+
rescue StandardError => e
|
89
|
+
error_result("Accrual generation failed: #{e.message}")
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
# Fetch loans eligible for accrual generation
|
95
|
+
#
|
96
|
+
# Eligible criteria:
|
97
|
+
# - Status is active, overdue, or disbursed
|
98
|
+
# - Has outstanding balance (remaining_amount > 0)
|
99
|
+
# - If loan_ids provided, only fetch those specific loans
|
100
|
+
#
|
101
|
+
# @return [ActiveRecord::Relation<Loan>] Collection of eligible loans
|
102
|
+
def fetch_eligible_loans
|
103
|
+
loans = Dscf::Credit::Loan
|
104
|
+
.where(status: %w[active overdue disbursed])
|
105
|
+
.where("remaining_amount > 0")
|
106
|
+
.includes(:credit_line, credit_line: :credit_line_specs)
|
107
|
+
|
108
|
+
loans = loans.where(id: loan_ids) if loan_ids.present?
|
109
|
+
|
110
|
+
loans
|
111
|
+
end
|
112
|
+
|
113
|
+
# Generate accruals for a single loan
|
114
|
+
#
|
115
|
+
# Generates:
|
116
|
+
# 1. Interest accrual based on daily interest rate and principal
|
117
|
+
# 2. Penalty accrual if loan is overdue
|
118
|
+
#
|
119
|
+
# @param loan [Dscf::Credit::Loan] The loan to generate accruals for
|
120
|
+
# @return [Array<Hash>] Array of accrual details created
|
121
|
+
def generate_accruals_for_loan(loan)
|
122
|
+
return [] if accrual_exists_for_date?(loan) && !force_regenerate
|
123
|
+
|
124
|
+
accruals = []
|
125
|
+
|
126
|
+
# Generate interest accrual
|
127
|
+
if loan.remaining_amount > 0
|
128
|
+
interest_accrual = generate_interest_accrual(loan)
|
129
|
+
accruals << interest_accrual if interest_accrual
|
130
|
+
end
|
131
|
+
|
132
|
+
# Generate penalty accrual if overdue
|
133
|
+
if loan_is_overdue?(loan)
|
134
|
+
penalty_accrual = generate_penalty_accrual(loan)
|
135
|
+
accruals << penalty_accrual if penalty_accrual
|
136
|
+
end
|
137
|
+
|
138
|
+
accruals
|
139
|
+
end
|
140
|
+
|
141
|
+
# Check if accruals already exist for the loan on the given date
|
142
|
+
#
|
143
|
+
# @param loan [Dscf::Credit::Loan] The loan to check
|
144
|
+
# @return [Boolean] True if accruals exist for the date
|
145
|
+
def accrual_exists_for_date?(loan)
|
146
|
+
loan.loan_accruals.where(applied_on: accrual_date).exists?
|
147
|
+
end
|
148
|
+
|
149
|
+
# Generate interest accrual for a loan
|
150
|
+
#
|
151
|
+
# Interest calculation:
|
152
|
+
# - Gets daily interest rate from active credit line spec
|
153
|
+
# - Daily interest = principal × daily_interest_rate
|
154
|
+
# - Note: interest_rate in credit_line_spec is already a daily rate, not annual
|
155
|
+
#
|
156
|
+
# @param loan [Dscf::Credit::Loan] The loan to generate interest for
|
157
|
+
# @return [Hash, nil] Accrual detail hash or nil if no interest
|
158
|
+
def generate_interest_accrual(loan)
|
159
|
+
credit_line_spec = loan.credit_line.credit_line_specs.active.first
|
160
|
+
|
161
|
+
return nil unless credit_line_spec
|
162
|
+
|
163
|
+
daily_interest_rate = credit_line_spec.interest_rate
|
164
|
+
return nil unless daily_interest_rate > 0
|
165
|
+
|
166
|
+
# Calculate daily interest (rate is already daily, no need to divide by 365)
|
167
|
+
daily_interest = (loan.remaining_amount * daily_interest_rate).round(2)
|
168
|
+
|
169
|
+
return nil if daily_interest <= 0
|
170
|
+
|
171
|
+
# Create interest accrual
|
172
|
+
accrual = Dscf::Credit::LoanAccrual.create!(
|
173
|
+
loan: loan,
|
174
|
+
accrual_type: "interest",
|
175
|
+
amount: daily_interest,
|
176
|
+
applied_on: accrual_date,
|
177
|
+
status: "pending"
|
178
|
+
)
|
179
|
+
|
180
|
+
{
|
181
|
+
loan_id: loan.id,
|
182
|
+
accrual_id: accrual.id,
|
183
|
+
accrual_type: "interest",
|
184
|
+
amount: daily_interest,
|
185
|
+
applied_on: accrual_date
|
186
|
+
}
|
187
|
+
end
|
188
|
+
|
189
|
+
# Generate penalty accrual for overdue loans
|
190
|
+
#
|
191
|
+
# Penalty calculation:
|
192
|
+
# - Gets daily penalty rate from active credit line spec
|
193
|
+
# - Daily penalty = principal × daily_penalty_rate
|
194
|
+
# - Only applies if loan.due_date < accrual_date
|
195
|
+
# - Note: penalty_rate in credit_line_spec is already a daily rate, not annual
|
196
|
+
#
|
197
|
+
# @param loan [Dscf::Credit::Loan] The loan to generate penalty for
|
198
|
+
# @return [Hash, nil] Accrual detail hash or nil if no penalty
|
199
|
+
def generate_penalty_accrual(loan)
|
200
|
+
credit_line_spec = loan.credit_line.credit_line_specs.active.first
|
201
|
+
|
202
|
+
return nil unless credit_line_spec
|
203
|
+
|
204
|
+
daily_penalty_rate = credit_line_spec.penalty_rate
|
205
|
+
return nil unless daily_penalty_rate > 0
|
206
|
+
|
207
|
+
# Calculate daily penalty (rate is already daily, no need to divide by 365)
|
208
|
+
daily_penalty = (loan.remaining_amount * daily_penalty_rate).round(2)
|
209
|
+
|
210
|
+
return nil if daily_penalty <= 0
|
211
|
+
|
212
|
+
# Create penalty accrual
|
213
|
+
accrual = Dscf::Credit::LoanAccrual.create!(
|
214
|
+
loan: loan,
|
215
|
+
accrual_type: "penalty",
|
216
|
+
amount: daily_penalty,
|
217
|
+
applied_on: accrual_date,
|
218
|
+
status: "pending"
|
219
|
+
)
|
220
|
+
|
221
|
+
{
|
222
|
+
loan_id: loan.id,
|
223
|
+
accrual_id: accrual.id,
|
224
|
+
accrual_type: "penalty",
|
225
|
+
amount: daily_penalty,
|
226
|
+
applied_on: accrual_date
|
227
|
+
}
|
228
|
+
end
|
229
|
+
|
230
|
+
# Check if loan is overdue based on due date and accrual date
|
231
|
+
#
|
232
|
+
# @param loan [Dscf::Credit::Loan] The loan to check
|
233
|
+
# @return [Boolean] True if loan is overdue
|
234
|
+
def loan_is_overdue?(loan)
|
235
|
+
loan.due_date < accrual_date || loan.status == "overdue"
|
236
|
+
end
|
237
|
+
|
238
|
+
# Build success result hash
|
239
|
+
#
|
240
|
+
# @param accrual_details [Array<Hash>] Details of accruals created
|
241
|
+
# @param errors [Array<Hash>] Any errors encountered
|
242
|
+
# @return [Hash] Success response
|
243
|
+
def success_result(accrual_details, errors)
|
244
|
+
loans_processed = accrual_details.map { |a| a[:loan_id] }.uniq.size
|
245
|
+
accruals_created = accrual_details.size
|
246
|
+
|
247
|
+
{
|
248
|
+
success: true,
|
249
|
+
accruals_created: accruals_created,
|
250
|
+
loans_processed: loans_processed,
|
251
|
+
accrual_details: accrual_details,
|
252
|
+
errors: errors,
|
253
|
+
message: "Successfully generated #{accruals_created} accruals for #{loans_processed} loans on #{accrual_date}"
|
254
|
+
}
|
255
|
+
end
|
256
|
+
|
257
|
+
# Build error result hash
|
258
|
+
#
|
259
|
+
# @param message [String] Error message
|
260
|
+
# @return [Hash] Error response
|
261
|
+
def error_result(message)
|
262
|
+
{
|
263
|
+
success: false,
|
264
|
+
accruals_created: 0,
|
265
|
+
loans_processed: 0,
|
266
|
+
accrual_details: [],
|
267
|
+
errors: [],
|
268
|
+
message: message
|
269
|
+
}
|
270
|
+
end
|
271
|
+
end
|
272
|
+
end
|