dscf-banking 0.5.2 → 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: 7fe37bdff67cefd5cc8827eba966f74185e155c3cf627fed75b4e27ad43ebdec
4
- data.tar.gz: 905268bd1b0f28a2e806fc7a950851b85915d7bab0354f6bd7473b40b196d3d2
3
+ metadata.gz: '0086738f163c88e967b2850c3b0c2e29804725e059996dd2edfd152a98c874b5'
4
+ data.tar.gz: 2bc163e912db1cb78a491610f88efc8d99f8455f034ed90f64031628bce1375c
5
5
  SHA512:
6
- metadata.gz: 8f525a5e131f5fa21f717101a98fd7698477e242818489338fca9b94caa6133bc0feb9d2eea3c2854564449d2454d68a493ca1b7e1797fc6cd64df91b826b5b5
7
- data.tar.gz: 97c43049bf4d9cbb18cce581dcac6e59b5cc219ac5ae03812edb6875426531a7325765e9ff2ad627bd124cb1786fe94ec06ff5a4442a05dc1ffd58fabf67a8d0
6
+ metadata.gz: 970cd3f5b838bcd3a5c74a9b2511569cd4ec7eeb7ecf28d90e32d9efda437707214514088d562a2e35bff1a43f7dc8b89a5abff66c56071a5ff3f2e2f3abc832
7
+ data.tar.gz: 29f3c5f3299b0b1dc1da0cfb11594f22a0c2970865482701097bd0492552165a90a76817b8acef394f63e4de15a5758571635236edd482f633ed700117af684f
@@ -426,7 +426,41 @@ module Dscf::Banking
426
426
  errors << "transactions must be an array"
427
427
  end
428
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
+
429
450
  errors
430
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
431
465
  end
432
466
  end
@@ -12,15 +12,16 @@ module Dscf::Banking
12
12
  @initial_balance = decimal(initial_balance)
13
13
  @start_date = to_date(start_date)
14
14
  @end_date = to_date(end_date)
15
+ @transactions_by_date = {}
15
16
  @transactions = normalize_transactions(transactions || [])
16
17
  end
17
18
 
18
19
  def call
19
20
  raise ArgumentError, "start_date must be before or equal to end_date" if @start_date > @end_date
20
21
 
21
- daily_rate = annual_interest_rate / BigDecimal(day_basis.to_s)
22
22
  balance = @initial_balance
23
23
  gross_interest = BigDecimal("0")
24
+ accrued_interest = BigDecimal("0")
24
25
  daily_breakdown = []
25
26
 
26
27
  (@start_date..@end_date).each do |date|
@@ -28,14 +29,25 @@ module Dscf::Banking
28
29
  daily_transaction_sum = daily_transactions.sum(BigDecimal("0")) { |transaction| transaction[:signed_amount] }
29
30
  balance += daily_transaction_sum
30
31
 
31
- daily_interest = balance * daily_rate
32
- gross_interest += daily_interest
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
33
45
 
34
46
  daily_breakdown << {
35
47
  date: date.iso8601,
36
48
  balance: money(balance),
37
49
  transaction_total: money(daily_transaction_sum),
38
- daily_interest: money(daily_interest)
50
+ daily_interest: money(posted_daily_interest)
39
51
  }
40
52
  end
41
53
 
@@ -64,6 +76,10 @@ module Dscf::Banking
64
76
  configured.present? ? decimal(configured) : DEFAULT_ANNUAL_INTEREST_RATE
65
77
  end
66
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
+
67
83
  def tax_rate
68
84
  configured = interest_configuration&.income_tax_rate
69
85
  configured.present? ? decimal(configured) : DEFAULT_TAX_RATE
@@ -78,10 +94,76 @@ module Dscf::Banking
78
94
  end
79
95
  end
80
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
+
81
159
  def normalize_transactions(transactions)
82
160
  return [] unless transactions.is_a?(Array)
83
161
 
84
- normalized = transactions.map do |transaction|
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
+
85
167
  date = to_date(transaction[:date] || transaction["date"])
86
168
  amount = decimal(transaction[:amount] || transaction["amount"])
87
169
  type = (transaction[:type] || transaction["type"]).to_s.downcase
@@ -104,7 +186,7 @@ module Dscf::Banking
104
186
  end
105
187
 
106
188
  def to_date(value)
107
- Date.parse(value.to_s)
189
+ Date.iso8601(value.to_s)
108
190
  rescue Date::Error
109
191
  raise ArgumentError, "Invalid date value: #{value}"
110
192
  end
@@ -116,7 +198,7 @@ module Dscf::Banking
116
198
  end
117
199
 
118
200
  def money(amount)
119
- decimal(amount).round(2).to_s("F")
201
+ format("%.2f", decimal(amount).round(2).to_f)
120
202
  end
121
203
 
122
204
  def accounting_records(gross_interest, tax_amount, net_interest)
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Banking
3
- VERSION = "0.5.2"
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.2
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-15 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