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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/dscf/credit/facilitator_applications_controller.rb +88 -5
  3. data/app/controllers/dscf/credit/loan_accruals_controller.rb +113 -0
  4. data/app/controllers/dscf/credit/loan_applications_controller.rb +5 -5
  5. data/app/controllers/dscf/credit/loan_profiles_controller.rb +2 -2
  6. data/app/controllers/dscf/credit/loans_controller.rb +2 -6
  7. data/app/controllers/dscf/credit/repayments_controller.rb +3 -3
  8. data/app/controllers/dscf/credit/scoring_parameters_controller.rb +9 -13
  9. data/app/controllers/dscf/credit/users_controller.rb +6 -0
  10. data/app/jobs/dscf/credit/generate_daily_accruals_job.rb +78 -0
  11. data/app/models/dscf/credit/facilitator_application.rb +0 -1
  12. data/app/models/dscf/credit/loan.rb +26 -5
  13. data/app/models/dscf/credit/loan_accrual.rb +25 -0
  14. data/app/serializers/dscf/credit/loan_accrual_serializer.rb +7 -0
  15. data/app/serializers/dscf/credit/loan_serializer.rb +24 -2
  16. data/app/services/dscf/credit/credit_scoring_engine.rb +8 -8
  17. data/app/services/dscf/credit/disbursement_service.rb +50 -8
  18. data/app/services/dscf/credit/loan_accrual_generator_service.rb +272 -0
  19. data/app/services/dscf/credit/repayment_service.rb +216 -87
  20. data/config/locales/en.yml +36 -0
  21. data/config/routes.rb +10 -0
  22. data/db/migrate/20250822092426_create_dscf_credit_facilitator_applications.rb +1 -1
  23. data/db/migrate/20250822092654_create_dscf_credit_loans.rb +0 -4
  24. data/db/migrate/20251003132939_create_dscf_credit_loan_accruals.rb +19 -0
  25. data/db/seeds.rb +20 -5
  26. data/lib/dscf/credit/version.rb +1 -1
  27. data/spec/factories/dscf/credit/loan_accruals.rb +29 -0
  28. data/spec/factories/dscf/credit/loans.rb +0 -4
  29. 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
- facilitation_fee: loan_terms[:facilitation_fee],
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
- new_available_limit = eligible_credit_line.available_limit - loan.total_loan_amount
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: loan.facilitation_fee.to_f,
119
- total_loan_amount: loan.total_loan_amount.to_f,
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