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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0086738f163c88e967b2850c3b0c2e29804725e059996dd2edfd152a98c874b5'
|
|
4
|
+
data.tar.gz: 2bc163e912db1cb78a491610f88efc8d99f8455f034ed90f64031628bce1375c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/lib/dscf/banking/version.rb
CHANGED
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.
|
|
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-
|
|
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
|