dscf-banking 0.5.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f558728cc00fd36f502cc21c0f342a1db0102f7c2b08b92b4fdc224e7a3a4839
4
- data.tar.gz: 47c41262ad655e43365f13a974cd253474e3dbf4ac0abdb46e03564288f20b42
3
+ metadata.gz: '0086738f163c88e967b2850c3b0c2e29804725e059996dd2edfd152a98c874b5'
4
+ data.tar.gz: 2bc163e912db1cb78a491610f88efc8d99f8455f034ed90f64031628bce1375c
5
5
  SHA512:
6
- metadata.gz: c2c57799b26eb5bbe257e20c9a044298021268e55689fbb62e32157dc1b674595b0406f603f95e82356bc865c63a313a662d1266588d967700846579341ae465
7
- data.tar.gz: 8d04ca7ec5e9ead3fa0d026999962fd5026a1cfb98825465ee455ba961f4606b406175bfe9152943cb5c2dbc19b1579ea5301b235e642116a34dc2eb0759da47
6
+ metadata.gz: 970cd3f5b838bcd3a5c74a9b2511569cd4ec7eeb7ecf28d90e32d9efda437707214514088d562a2e35bff1a43f7dc8b89a5abff66c56071a5ff3f2e2f3abc832
7
+ data.tar.gz: 29f3c5f3299b0b1dc1da0cfb11594f22a0c2970865482701097bd0492552165a90a76817b8acef394f63e4de15a5758571635236edd482f633ed700117af684f
@@ -274,6 +274,46 @@ module Dscf::Banking
274
274
  }, status: :not_found
275
275
  end
276
276
 
277
+ def interest_simulations
278
+ account_number = params[:account_number].presence || params[:id]
279
+ account = Dscf::Banking::Account.find_by(account_number: account_number)
280
+
281
+ unless account
282
+ return render json: {
283
+ success: false,
284
+ error: "Account not found"
285
+ }, status: :not_found
286
+ end
287
+
288
+ validation_errors = interest_simulation_validation_errors
289
+ if validation_errors.any?
290
+ return render json: {
291
+ success: false,
292
+ error: "Invalid interest simulation parameters",
293
+ errors: validation_errors
294
+ }, status: :unprocessable_entity
295
+ end
296
+
297
+ simulation = Dscf::Banking::InterestSimulationService.new(
298
+ account: account,
299
+ initial_balance: params[:initial_balance],
300
+ start_date: params[:start_date],
301
+ end_date: params[:end_date],
302
+ transactions: params[:transactions] || []
303
+ ).call
304
+
305
+ render json: {
306
+ success: true,
307
+ data: simulation
308
+ }
309
+ rescue ArgumentError => e
310
+ render json: {
311
+ success: false,
312
+ error: "Invalid interest simulation parameters",
313
+ errors: [ e.message ]
314
+ }, status: :unprocessable_entity
315
+ end
316
+
277
317
  private
278
318
 
279
319
  def transaction_data(transaction)
@@ -375,5 +415,52 @@ module Dscf::Banking
375
415
  update: [ :virtual_account_product, :application ]
376
416
  }
377
417
  end
418
+
419
+ def interest_simulation_validation_errors
420
+ errors = []
421
+ errors << "initial_balance is required" if params[:initial_balance].blank?
422
+ errors << "start_date is required" if params[:start_date].blank?
423
+ errors << "end_date is required" if params[:end_date].blank?
424
+
425
+ if params[:transactions].present? && !params[:transactions].is_a?(Array)
426
+ errors << "transactions must be an array"
427
+ end
428
+
429
+ if params[:initial_balance].present? && !valid_decimal?(params[:initial_balance])
430
+ errors << "initial_balance must be a valid number"
431
+ end
432
+
433
+ start_date = nil
434
+ end_date = nil
435
+
436
+ if params[:start_date].present?
437
+ start_date = parse_iso_date(params[:start_date])
438
+ errors << "start_date must be a valid date" unless start_date
439
+ end
440
+
441
+ if params[:end_date].present?
442
+ end_date = parse_iso_date(params[:end_date])
443
+ errors << "end_date must be a valid date" unless end_date
444
+ end
445
+
446
+ if start_date && end_date && start_date > end_date
447
+ errors << "start_date must be before or equal to end_date"
448
+ end
449
+
450
+ errors
451
+ end
452
+
453
+ def valid_decimal?(value)
454
+ BigDecimal(value.to_s)
455
+ true
456
+ rescue ArgumentError
457
+ false
458
+ end
459
+
460
+ def parse_iso_date(value)
461
+ Date.iso8601(value.to_s)
462
+ rescue Date::Error
463
+ nil
464
+ end
378
465
  end
379
466
  end
@@ -0,0 +1,227 @@
1
+ require "bigdecimal"
2
+ require "date"
3
+
4
+ module Dscf::Banking
5
+ class InterestSimulationService
6
+ DEFAULT_ANNUAL_INTEREST_RATE = BigDecimal("0.05")
7
+ # Fallback tax rate when no product-level tax is configured.
8
+ DEFAULT_TAX_RATE = BigDecimal("0.15")
9
+
10
+ def initialize(account:, initial_balance:, start_date:, end_date:, transactions: [])
11
+ @account = account
12
+ @initial_balance = decimal(initial_balance)
13
+ @start_date = to_date(start_date)
14
+ @end_date = to_date(end_date)
15
+ @transactions_by_date = {}
16
+ @transactions = normalize_transactions(transactions || [])
17
+ end
18
+
19
+ def call
20
+ raise ArgumentError, "start_date must be before or equal to end_date" if @start_date > @end_date
21
+
22
+ balance = @initial_balance
23
+ gross_interest = BigDecimal("0")
24
+ accrued_interest = BigDecimal("0")
25
+ daily_breakdown = []
26
+
27
+ (@start_date..@end_date).each do |date|
28
+ daily_transactions = @transactions_by_date[date] || []
29
+ daily_transaction_sum = daily_transactions.sum(BigDecimal("0")) { |transaction| transaction[:signed_amount] }
30
+ balance += daily_transaction_sum
31
+
32
+ daily_rate = annual_interest_rate_for(balance) / BigDecimal(day_basis.to_s)
33
+ raw_daily_interest = eligible_for_interest?(date, balance) ? balance * daily_rate : BigDecimal("0")
34
+ accrued_interest += raw_daily_interest
35
+
36
+ posted_daily_interest = BigDecimal("0")
37
+ if accrual_posting_due?(date)
38
+ posted_daily_interest = accrued_interest
39
+ accrued_interest = BigDecimal("0")
40
+ gross_interest += posted_daily_interest
41
+
42
+ # Compound mode credits posted interest into principal for subsequent days.
43
+ balance += posted_daily_interest if compound_calculation? && compounding_due?(date)
44
+ end
45
+
46
+ daily_breakdown << {
47
+ date: date.iso8601,
48
+ balance: money(balance),
49
+ transaction_total: money(daily_transaction_sum),
50
+ daily_interest: money(posted_daily_interest)
51
+ }
52
+ end
53
+
54
+ tax_amount = gross_interest * tax_rate
55
+ net_interest = gross_interest - tax_amount
56
+
57
+ {
58
+ gross_interest: money(gross_interest),
59
+ tax_amount: money(tax_amount),
60
+ net_interest: money(net_interest),
61
+ daily_breakdown: daily_breakdown,
62
+ accounting_records: accounting_records(gross_interest, tax_amount, net_interest)
63
+ }
64
+ end
65
+
66
+ private
67
+
68
+ def interest_configuration
69
+ @interest_configuration ||= Dscf::Banking::InterestConfiguration
70
+ .active
71
+ .find_by(virtual_account_product_id: @account.virtual_account_product_id)
72
+ end
73
+
74
+ def annual_interest_rate
75
+ configured = interest_configuration&.annual_interest_rate
76
+ configured.present? ? decimal(configured) : DEFAULT_ANNUAL_INTEREST_RATE
77
+ end
78
+
79
+ def annual_interest_rate_for(balance)
80
+ interest_rate_tier_for(balance)&.yield_self { |tier| decimal(tier.interest_rate) } || annual_interest_rate
81
+ end
82
+
83
+ def tax_rate
84
+ configured = interest_configuration&.income_tax_rate
85
+ configured.present? ? decimal(configured) : DEFAULT_TAX_RATE
86
+ end
87
+
88
+ def day_basis
89
+ case interest_configuration&.interest_basis
90
+ when "actual_360", "thirty_360"
91
+ 360
92
+ else
93
+ 365
94
+ end
95
+ end
96
+
97
+ def compound_calculation?
98
+ interest_configuration&.calculation_method == "compound"
99
+ end
100
+
101
+ def accrual_posting_due?(date)
102
+ return date == date.end_of_month || date == @end_date if monthly_accrual_mode?
103
+
104
+ true
105
+ end
106
+
107
+ def compounding_due?(date)
108
+ return date == date.end_of_month || date == @end_date if monthly_compounding_mode?
109
+
110
+ true
111
+ end
112
+
113
+ def monthly_accrual_mode?
114
+ interest_configuration&.accrual_frequency == "monthly"
115
+ end
116
+
117
+ def monthly_compounding_mode?
118
+ interest_configuration&.compounding_period == "monthly"
119
+ end
120
+
121
+ def eligible_for_interest?(date, balance)
122
+ meets_minimum_balance?(balance) && within_promotional_window?(date)
123
+ end
124
+
125
+ def meets_minimum_balance?(balance)
126
+ balance >= minimum_balance_for_interest
127
+ end
128
+
129
+ def minimum_balance_for_interest
130
+ configured = interest_configuration&.minimum_balance_for_interest
131
+ configured.present? ? decimal(configured) : BigDecimal("0")
132
+ end
133
+
134
+ def interest_rate_tier_for(balance)
135
+ interest_rate_tiers.find do |tier|
136
+ lower_bound_satisfied = balance >= decimal(tier.balance_min)
137
+ upper_bound_satisfied = tier.balance_max.blank? || balance <= decimal(tier.balance_max)
138
+
139
+ lower_bound_satisfied && upper_bound_satisfied
140
+ end
141
+ end
142
+
143
+ def interest_rate_tiers
144
+ @interest_rate_tiers ||= begin
145
+ if interest_configuration
146
+ Dscf::Banking::InterestRateTier.by_interest_config(interest_configuration.id).ordered.to_a
147
+ else
148
+ []
149
+ end
150
+ end
151
+ end
152
+
153
+ def within_promotional_window?(date)
154
+ return true unless interest_configuration&.promotional_start_date.present? || interest_configuration&.promotional_end_date.present?
155
+
156
+ date >= interest_configuration.promotional_start_date && date <= interest_configuration.promotional_end_date
157
+ end
158
+
159
+ def normalize_transactions(transactions)
160
+ return [] unless transactions.is_a?(Array)
161
+
162
+ normalized = transactions.each_with_index.map do |transaction, index|
163
+ unless transaction.respond_to?(:[]) && (transaction.is_a?(Hash) || transaction.respond_to?(:to_h))
164
+ raise ArgumentError, "Invalid transaction payload at index #{index}"
165
+ end
166
+
167
+ date = to_date(transaction[:date] || transaction["date"])
168
+ amount = decimal(transaction[:amount] || transaction["amount"])
169
+ type = (transaction[:type] || transaction["type"]).to_s.downcase
170
+
171
+ {
172
+ date: date,
173
+ signed_amount: signed_amount(amount, type)
174
+ }
175
+ end
176
+
177
+ @transactions_by_date = normalized.group_by { |transaction| transaction[:date] }
178
+ normalized
179
+ end
180
+
181
+ def signed_amount(amount, type)
182
+ return amount * -1 if %w[debit withdrawal outflow].include?(type) && amount.positive?
183
+ return amount.abs if %w[credit deposit inflow].include?(type)
184
+
185
+ amount
186
+ end
187
+
188
+ def to_date(value)
189
+ Date.iso8601(value.to_s)
190
+ rescue Date::Error
191
+ raise ArgumentError, "Invalid date value: #{value}"
192
+ end
193
+
194
+ def decimal(value)
195
+ BigDecimal(value.to_s)
196
+ rescue ArgumentError
197
+ raise ArgumentError, "Invalid numeric value: #{value}"
198
+ end
199
+
200
+ def money(amount)
201
+ format("%.2f", decimal(amount).round(2).to_f)
202
+ end
203
+
204
+ def accounting_records(gross_interest, tax_amount, net_interest)
205
+ [
206
+ {
207
+ date: @end_date.iso8601,
208
+ type: "gross_interest",
209
+ amount: money(gross_interest),
210
+ currency: @account.currency
211
+ },
212
+ {
213
+ date: @end_date.iso8601,
214
+ type: "tax",
215
+ amount: money(tax_amount),
216
+ currency: @account.currency
217
+ },
218
+ {
219
+ date: @end_date.iso8601,
220
+ type: "net_interest",
221
+ amount: money(net_interest),
222
+ currency: @account.currency
223
+ }
224
+ ]
225
+ end
226
+ end
227
+ end
data/config/routes.rb CHANGED
@@ -7,6 +7,7 @@ Dscf::Banking::Engine.routes.draw do
7
7
  post :activate
8
8
  post :suspend
9
9
  post :close
10
+ post :interest_simulations
10
11
  get :transactions
11
12
  get :full_name
12
13
  end
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Banking
3
- VERSION = "0.5.1"
3
+ VERSION = "0.5.3"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dscf-banking
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat Efrem
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-03-14 00:00:00.000000000 Z
10
+ date: 2026-03-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -472,6 +472,7 @@ files:
472
472
  - app/services/dscf/banking/account_creation_service.rb
473
473
  - app/services/dscf/banking/base_transaction_service.rb
474
474
  - app/services/dscf/banking/deposit_service.rb
475
+ - app/services/dscf/banking/interest_simulation_service.rb
475
476
  - app/services/dscf/banking/transfer_service.rb
476
477
  - app/services/dscf/banking/voucher_service.rb
477
478
  - app/services/dscf/banking/withdrawal_service.rb