dscf-credit 0.1.7 → 0.1.9
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/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 +9 -4
- data/app/controllers/dscf/credit/loans_controller.rb +2 -6
- data/app/controllers/dscf/credit/repayments_controller.rb +2 -3
- data/app/controllers/dscf/credit/scoring_parameters_controller.rb +1 -1
- 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/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 +35 -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 +34 -0
- data/config/routes.rb +6 -0
- 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
@@ -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
|
@@ -1,15 +1,74 @@
|
|
1
1
|
module Dscf::Credit
|
2
|
+
# RepaymentService handles loan repayment processing with automatic payment allocation
|
3
|
+
# across facilitation fees, penalties, interest, and principal.
|
4
|
+
#
|
5
|
+
# @example Basic usage
|
6
|
+
# loan = Dscf::Credit::Loan.find(loan_id)
|
7
|
+
# service = Dscf::Credit::RepaymentService.new(loan, 1500.00)
|
8
|
+
# result = service.process_repayment
|
9
|
+
#
|
10
|
+
# if result[:success]
|
11
|
+
# puts "Payment processed: #{result[:message]}"
|
12
|
+
# puts "Remaining balance: #{result[:payment_details][:remaining_balance]}"
|
13
|
+
# else
|
14
|
+
# puts "Error: #{result[:error]}"
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# @see docs/REPAYMENT_SERVICE_DOCUMENTATION.md for detailed documentation
|
2
18
|
class RepaymentService
|
3
|
-
attr_reader :loan, :payment_amount
|
19
|
+
attr_reader :loan, :payment_amount
|
4
20
|
|
5
|
-
|
21
|
+
# Initialize the repayment service
|
22
|
+
#
|
23
|
+
# @param loan [Dscf::Credit::Loan] The loan to process repayment for
|
24
|
+
# @param payment_amount [Numeric] The amount being paid (converted to float)
|
25
|
+
def initialize(loan, payment_amount)
|
6
26
|
@loan = loan
|
7
27
|
@payment_amount = payment_amount.to_f
|
8
|
-
@current_user = current_user
|
9
28
|
end
|
10
29
|
|
11
|
-
# Process loan repayment
|
12
|
-
#
|
30
|
+
# Process loan repayment with automatic allocation and status updates
|
31
|
+
#
|
32
|
+
# Payment allocation priority (waterfall method):
|
33
|
+
# 1. Facilitation Fee (from loan_accruals where accrual_type='facilitation_fee' and status='pending')
|
34
|
+
# 2. Penalties (from loan_accruals where accrual_type='penalty' and status='pending')
|
35
|
+
# 3. Interest (from loan_accruals where accrual_type='interest' and status='pending')
|
36
|
+
# 4. Principal (from loan.remaining_amount)
|
37
|
+
#
|
38
|
+
# Process steps:
|
39
|
+
# 1. Calculate payment allocation across loan components
|
40
|
+
# 2. Update loan record (remaining_amount only)
|
41
|
+
# 3. Mark accruals as paid (with splitting for partial payments)
|
42
|
+
# 4. Update loan status based on remaining balance
|
43
|
+
# 5. Reactivate credit facilities if loan fully paid
|
44
|
+
#
|
45
|
+
# @return [Hash] Result hash with the following structure:
|
46
|
+
# - :success [Boolean] Whether the operation succeeded
|
47
|
+
# - :loan [Dscf::Credit::Loan, nil] Updated loan object (nil on failure)
|
48
|
+
# - :payment_details [Hash] Detailed payment allocation information
|
49
|
+
# - :message [String] Success message
|
50
|
+
# - :error [String] Error message (only present on failure)
|
51
|
+
#
|
52
|
+
# @example Success response
|
53
|
+
# {
|
54
|
+
# success: true,
|
55
|
+
# loan: #<Dscf::Credit::Loan>,
|
56
|
+
# payment_details: {
|
57
|
+
# payment_amount: 1500.00,
|
58
|
+
# allocation: { ... },
|
59
|
+
# new_loan_status: "active",
|
60
|
+
# paid_off: false,
|
61
|
+
# remaining_balance: 3600.00
|
62
|
+
# },
|
63
|
+
# message: "Payment processed successfully"
|
64
|
+
# }
|
65
|
+
#
|
66
|
+
# @example Error response
|
67
|
+
# {
|
68
|
+
# success: false,
|
69
|
+
# loan: nil,
|
70
|
+
# error: "Payment amount exceeds total outstanding balance"
|
71
|
+
# }
|
13
72
|
def process_repayment
|
14
73
|
return error_result("Loan not found") unless loan
|
15
74
|
return error_result("Invalid payment amount") unless payment_amount > 0
|
@@ -24,8 +83,6 @@ module Dscf::Credit
|
|
24
83
|
|
25
84
|
update_loan_status
|
26
85
|
|
27
|
-
create_payment_transaction(payment_allocation)
|
28
|
-
|
29
86
|
reactivate_facilities_if_paid_off
|
30
87
|
|
31
88
|
success_result(payment_allocation)
|
@@ -36,10 +93,23 @@ module Dscf::Credit
|
|
36
93
|
|
37
94
|
private
|
38
95
|
|
96
|
+
# Calculate how the payment should be allocated across loan components
|
97
|
+
#
|
98
|
+
# Uses waterfall allocation: payment is applied in priority order until exhausted.
|
99
|
+
# Queries loan_accruals table for current pending penalties and interest.
|
100
|
+
#
|
101
|
+
# @return [Hash] Allocation details including:
|
102
|
+
# - :total_outstanding - Total amount owed
|
103
|
+
# - :payment_amount - Amount being paid
|
104
|
+
# - :facilitation_payment - Amount applied to facilitation fee
|
105
|
+
# - :penalty_payment - Amount applied to penalties
|
106
|
+
# - :interest_payment - Amount applied to interest
|
107
|
+
# - :principal_payment - Amount applied to principal
|
108
|
+
# - :remaining_* - Amounts still owed for each component
|
39
109
|
def calculate_payment_allocation
|
40
|
-
current_facilitation_fee = loan.facilitation_fee
|
41
|
-
current_penalty = loan.
|
42
|
-
current_interest = loan.
|
110
|
+
current_facilitation_fee = loan.loan_accruals.pending.by_type("facilitation_fee").sum(:amount)
|
111
|
+
current_penalty = loan.loan_accruals.pending.by_type("penalty").sum(:amount)
|
112
|
+
current_interest = loan.loan_accruals.pending.by_type("interest").sum(:amount)
|
43
113
|
current_principal = loan.remaining_amount || 0
|
44
114
|
|
45
115
|
total_outstanding = current_facilitation_fee + current_penalty + current_interest + current_principal
|
@@ -71,86 +141,138 @@ module Dscf::Credit
|
|
71
141
|
}
|
72
142
|
end
|
73
143
|
|
144
|
+
# Apply the calculated payment allocation to the loan and accruals
|
145
|
+
#
|
146
|
+
# Updates:
|
147
|
+
# 1. Loan record (remaining_amount only)
|
148
|
+
# 2. Loan accruals (marks facilitation_fee, penalties, and interest as paid)
|
149
|
+
#
|
150
|
+
# @param allocation [Hash] The allocation hash from calculate_payment_allocation
|
151
|
+
# @return [void]
|
74
152
|
def process_payment_allocation(allocation)
|
153
|
+
# Update loan record (only remaining_amount now)
|
75
154
|
loan.update!(
|
76
|
-
facilitation_fee: allocation[:remaining_facilitation_fee],
|
77
|
-
accrued_penalty: allocation[:remaining_penalty],
|
78
|
-
accrued_interest: allocation[:remaining_interest],
|
79
155
|
remaining_amount: allocation[:remaining_principal]
|
80
156
|
)
|
81
|
-
end
|
82
|
-
|
83
|
-
def update_loan_status
|
84
|
-
total_remaining = (loan.facilitation_fee || 0) +
|
85
|
-
(loan.accrued_penalty || 0) +
|
86
|
-
(loan.accrued_interest || 0) +
|
87
|
-
(loan.remaining_amount || 0)
|
88
|
-
|
89
|
-
if total_remaining <= 0.01
|
90
|
-
loan.update!(status: "paid")
|
91
|
-
else
|
92
|
-
loan.update!(status: "active") if loan.status == "overdue"
|
93
|
-
end
|
94
|
-
end
|
95
|
-
|
96
|
-
def create_payment_transaction(allocation)
|
97
|
-
Dscf::Credit::LoanTransaction.create!(
|
98
|
-
loan: loan,
|
99
|
-
transaction_type: "repayment",
|
100
|
-
amount: payment_amount,
|
101
|
-
transaction_reference: "REPAY-#{loan.id}-#{Time.current.to_i}",
|
102
|
-
status: "completed"
|
103
|
-
)
|
104
|
-
|
105
|
-
create_allocation_transactions(allocation) if payment_amount > 100 # Only for significant payments
|
106
|
-
end
|
107
|
-
|
108
|
-
def create_allocation_transactions(allocation)
|
109
|
-
transactions = []
|
110
157
|
|
158
|
+
# Mark paid facilitation fee accruals
|
111
159
|
if allocation[:facilitation_payment] > 0
|
112
|
-
|
113
|
-
transaction_type: "fee_charge",
|
114
|
-
amount: allocation[:facilitation_payment],
|
115
|
-
reference_suffix: "FEE"
|
116
|
-
}
|
160
|
+
mark_accruals_as_paid("facilitation_fee", allocation[:facilitation_payment])
|
117
161
|
end
|
118
162
|
|
163
|
+
# Mark paid penalty accruals
|
119
164
|
if allocation[:penalty_payment] > 0
|
120
|
-
|
121
|
-
transaction_type: "penalty",
|
122
|
-
amount: allocation[:penalty_payment],
|
123
|
-
reference_suffix: "PENALTY"
|
124
|
-
}
|
165
|
+
mark_accruals_as_paid("penalty", allocation[:penalty_payment])
|
125
166
|
end
|
126
167
|
|
168
|
+
# Mark paid interest accruals
|
127
169
|
if allocation[:interest_payment] > 0
|
128
|
-
|
129
|
-
transaction_type: "interest_accrual",
|
130
|
-
amount: allocation[:interest_payment],
|
131
|
-
reference_suffix: "INTEREST"
|
132
|
-
}
|
170
|
+
mark_accruals_as_paid("interest", allocation[:interest_payment])
|
133
171
|
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Mark loan accruals as paid, handling partial payments by splitting accruals
|
175
|
+
#
|
176
|
+
# Processes accruals in chronological order (oldest first by applied_on date).
|
177
|
+
# When payment is insufficient to cover an entire accrual:
|
178
|
+
# 1. Updates the original accrual to "paid" status with the paid amount
|
179
|
+
# 2. Creates a new "pending" accrual for the remaining unpaid amount
|
180
|
+
#
|
181
|
+
# @param accrual_type [String] Type of accrual to pay ('penalty' or 'interest')
|
182
|
+
# @param payment_amount [Float] Amount available to pay towards these accruals
|
183
|
+
# @return [void]
|
184
|
+
#
|
185
|
+
# @example Full payment
|
186
|
+
# # Accrual: { id: 1, amount: 100, status: 'pending' }
|
187
|
+
# # Payment: 100
|
188
|
+
# # Result: { id: 1, amount: 100, status: 'paid' }
|
189
|
+
#
|
190
|
+
# @example Partial payment with split
|
191
|
+
# # Accrual: { id: 1, amount: 100, status: 'pending' }
|
192
|
+
# # Payment: 60
|
193
|
+
# # Result:
|
194
|
+
# # { id: 1, amount: 60, status: 'paid' } # Paid portion
|
195
|
+
# # { id: 2, amount: 40, status: 'pending' } # New accrual for remainder
|
196
|
+
def mark_accruals_as_paid(accrual_type, payment_amount)
|
197
|
+
pending_accruals = loan.loan_accruals.pending.by_type(accrual_type).order(:applied_on)
|
198
|
+
remaining_payment = payment_amount
|
134
199
|
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
200
|
+
pending_accruals.each do |accrual|
|
201
|
+
break if remaining_payment <= 0
|
202
|
+
|
203
|
+
if remaining_payment >= accrual.amount
|
204
|
+
accrual.update!(status: "paid")
|
205
|
+
remaining_payment -= accrual.amount
|
206
|
+
else
|
207
|
+
# Partial payment: split the accrual
|
208
|
+
paid_amount = remaining_payment
|
209
|
+
remaining_amount = accrual.amount - paid_amount
|
210
|
+
|
211
|
+
# Update original accrual to paid status with reduced amount
|
212
|
+
accrual.update!(amount: paid_amount, status: "paid")
|
213
|
+
|
214
|
+
# Create a new pending accrual for the remaining amount
|
215
|
+
Dscf::Credit::LoanAccrual.create!(
|
216
|
+
loan: loan,
|
217
|
+
accrual_type: accrual_type,
|
218
|
+
amount: remaining_amount,
|
219
|
+
applied_on: accrual.applied_on,
|
220
|
+
status: "pending"
|
221
|
+
)
|
222
|
+
|
223
|
+
remaining_payment = 0
|
224
|
+
end
|
141
225
|
end
|
226
|
+
end
|
142
227
|
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
228
|
+
# Update loan status based on remaining balance
|
229
|
+
#
|
230
|
+
# Status transitions:
|
231
|
+
# - If total remaining ≤ 0.01 ETB → status: "paid"
|
232
|
+
# - If was "overdue" and balance > 0 → status: "active"
|
233
|
+
# - Otherwise → status unchanged
|
234
|
+
#
|
235
|
+
# Total remaining includes:
|
236
|
+
# - Facilitation fee (from loan_accruals)
|
237
|
+
# - Pending penalties (from loan_accruals)
|
238
|
+
# - Pending interest (from loan_accruals)
|
239
|
+
# - Remaining principal (from loan record)
|
240
|
+
#
|
241
|
+
# @return [void]
|
242
|
+
def update_loan_status
|
243
|
+
pending_facilitation_fee = loan.loan_accruals.pending.by_type("facilitation_fee").sum(:amount)
|
244
|
+
pending_penalty = loan.loan_accruals.pending.by_type("penalty").sum(:amount)
|
245
|
+
pending_interest = loan.loan_accruals.pending.by_type("interest").sum(:amount)
|
246
|
+
|
247
|
+
total_remaining = pending_facilitation_fee +
|
248
|
+
pending_penalty +
|
249
|
+
pending_interest +
|
250
|
+
(loan.remaining_amount || 0)
|
251
|
+
|
252
|
+
if total_remaining <= 0.01
|
253
|
+
loan.update!(status: "paid")
|
254
|
+
else
|
255
|
+
loan.update!(status: "active") if loan.status == "overdue"
|
151
256
|
end
|
152
257
|
end
|
153
258
|
|
259
|
+
# Reactivate locked credit facilities when loan is fully paid off
|
260
|
+
#
|
261
|
+
# Only runs if loan.status == "paid"
|
262
|
+
#
|
263
|
+
# Process:
|
264
|
+
# 1. Find all eligible credit lines with 0 available_limit (locked)
|
265
|
+
# 2. For each locked line, recalculate available_limit based on:
|
266
|
+
# - Credit limit (total allowed)
|
267
|
+
# - Minus current usage from other active loans
|
268
|
+
#
|
269
|
+
# This allows the borrower to access credit again after repayment.
|
270
|
+
#
|
271
|
+
# @return [void]
|
272
|
+
#
|
273
|
+
# @example
|
274
|
+
# # Before: eligible_line.available_limit = 0 (locked)
|
275
|
+
# # After payment: eligible_line.available_limit = 7000 (reactivated)
|
154
276
|
def reactivate_facilities_if_paid_off
|
155
277
|
return unless loan.status == "paid"
|
156
278
|
|
@@ -166,10 +288,14 @@ module Dscf::Credit
|
|
166
288
|
|
167
289
|
eligible_line.update!(available_limit: new_available_limit)
|
168
290
|
end
|
169
|
-
|
170
|
-
update_loan_profile_available_amount(loan_profile)
|
171
291
|
end
|
172
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
|
173
299
|
def calculate_current_usage_for_credit_line(credit_line)
|
174
300
|
credit_line.loans
|
175
301
|
.where(status: [ "active", "overdue", "disbursed" ])
|
@@ -177,34 +303,37 @@ module Dscf::Credit
|
|
177
303
|
.sum(:remaining_amount)
|
178
304
|
end
|
179
305
|
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
.sum(:remaining_amount)
|
185
|
-
|
186
|
-
new_available_amount = [ loan_profile.total_amount - total_usage, 0 ].max
|
187
|
-
loan_profile.update!(available_amount: new_available_amount)
|
188
|
-
end
|
189
|
-
|
306
|
+
# Build success result hash
|
307
|
+
#
|
308
|
+
# @param allocation [Hash] The allocation hash with payment details
|
309
|
+
# @return [Hash] Success response with loan, payment details, and message
|
190
310
|
def success_result(allocation)
|
311
|
+
loan.reload
|
312
|
+
pending_facilitation_fee = loan.loan_accruals.pending.by_type("facilitation_fee").sum(:amount)
|
313
|
+
pending_penalty = loan.loan_accruals.pending.by_type("penalty").sum(:amount)
|
314
|
+
pending_interest = loan.loan_accruals.pending.by_type("interest").sum(:amount)
|
315
|
+
|
191
316
|
{
|
192
317
|
success: true,
|
193
|
-
loan: loan
|
318
|
+
loan: loan,
|
194
319
|
payment_details: {
|
195
320
|
payment_amount: payment_amount,
|
196
321
|
allocation: allocation,
|
197
322
|
new_loan_status: loan.status,
|
198
323
|
paid_off: loan.status == "paid",
|
199
|
-
remaining_balance:
|
200
|
-
|
201
|
-
|
324
|
+
remaining_balance: pending_facilitation_fee +
|
325
|
+
pending_penalty +
|
326
|
+
pending_interest +
|
202
327
|
(loan.remaining_amount || 0)
|
203
328
|
},
|
204
329
|
message: loan.status == "paid" ? "Loan fully paid off" : "Payment processed successfully"
|
205
330
|
}
|
206
331
|
end
|
207
332
|
|
333
|
+
# Build error result hash
|
334
|
+
#
|
335
|
+
# @param message [String] Error message describing what went wrong
|
336
|
+
# @return [Hash] Error response with success: false and error message
|
208
337
|
def error_result(message)
|
209
338
|
{
|
210
339
|
success: false,
|
data/config/locales/en.yml
CHANGED
@@ -102,6 +102,40 @@ en:
|
|
102
102
|
calculate_facility_limits: "Failed to calculate facility limits"
|
103
103
|
apply_risk_factor: "Failed to apply risk factor"
|
104
104
|
|
105
|
+
loan_accrual:
|
106
|
+
success:
|
107
|
+
index: "Loan accruals retrieved successfully"
|
108
|
+
show: "Loan accrual details retrieved successfully"
|
109
|
+
create: "Loan accrual created successfully"
|
110
|
+
update: "Loan accrual updated successfully"
|
111
|
+
destroy: "Loan accrual deleted successfully"
|
112
|
+
mark_paid: "Loan accrual marked as paid successfully"
|
113
|
+
generate: "Loan accruals generated successfully"
|
114
|
+
statistics: "Loan accrual statistics retrieved successfully"
|
115
|
+
errors:
|
116
|
+
index: "Failed to retrieve loan accruals"
|
117
|
+
show: "Failed to retrieve loan accrual details"
|
118
|
+
create: "Failed to create loan accrual"
|
119
|
+
update: "Failed to update loan accrual"
|
120
|
+
destroy: "Failed to delete loan accrual"
|
121
|
+
mark_paid: "Failed to mark loan accrual as paid"
|
122
|
+
invalid_accrual_type: "Invalid accrual type"
|
123
|
+
amount_must_be_positive: "Accrual amount must be positive"
|
124
|
+
generate: "Failed to generate loan accruals"
|
125
|
+
statistics: "Failed to retrieve loan accrual statistics"
|
126
|
+
|
127
|
+
loan:
|
128
|
+
success:
|
129
|
+
index: "Loans retrieved successfully"
|
130
|
+
show: "Loan details retrieved successfully"
|
131
|
+
create: "Loan created successfully"
|
132
|
+
update: "Loan updated successfully"
|
133
|
+
errors:
|
134
|
+
index: "Failed to retrieve loans"
|
135
|
+
show: "Failed to retrieve loan details"
|
136
|
+
create: "Failed to create loan"
|
137
|
+
update: "Failed to update loan"
|
138
|
+
|
105
139
|
parameter_normalizer:
|
106
140
|
success:
|
107
141
|
index: "Parameter normalizers retrieved successfully"
|