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.
@@ -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, :current_user
19
+ attr_reader :loan, :payment_amount
4
20
 
5
- def initialize(loan, payment_amount, current_user)
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 by decreasing loan balance and updating related records
12
- # @return [Hash] Result containing payment details and updated loan status
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 || 0
41
- current_penalty = loan.accrued_penalty || 0
42
- current_interest = loan.accrued_interest || 0
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
- transactions << {
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
- transactions << {
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
- transactions << {
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
- if allocation[:principal_payment] > 0
136
- transactions << {
137
- transaction_type: "repayment",
138
- amount: allocation[:principal_payment],
139
- reference_suffix: "PRINCIPAL"
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
- transactions.each_with_index do |transaction_data, index|
144
- Dscf::Credit::LoanTransaction.create!(
145
- loan: loan,
146
- transaction_type: transaction_data[:transaction_type],
147
- amount: transaction_data[:amount],
148
- transaction_reference: "#{transaction_data[:reference_suffix]}-#{loan.id}-#{Time.current.to_i}-#{index}",
149
- status: "completed"
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
- def update_loan_profile_available_amount(loan_profile)
181
- # Recalculate available amount based on total limit minus current usage
182
- total_usage = loan_profile.loans
183
- .where(status: [ "active", "overdue", "disbursed" ])
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.reload,
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: (loan.facilitation_fee || 0) +
200
- (loan.accrued_penalty || 0) +
201
- (loan.accrued_interest || 0) +
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,
@@ -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"