loan_creator 0.9.1 → 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e207b2a1a30e9f07b773f9d3071c8df1cb815e17602661efb72d4315ead65dba
4
- data.tar.gz: c6760d02448a8a9bd6f40fa992aa09f1d716a06b94e2ce5afa8b54eddfeeddf6
3
+ metadata.gz: 9a85f9d61c45df0f972e329a33a1beedbedc1e0090d481613e93ad90d66dea98
4
+ data.tar.gz: 7dab891b9a5d3acefb8d6cd43eef0bdb5cfe511631f5674ae655046cda11ca25
5
5
  SHA512:
6
- metadata.gz: 75d786838e61c1f342da8a3ebf3e0264e42f0e80909bc22ffe63f5c76acf4a1c36e1130ebcbcb4f8c72d87f7a99735c8e3d70f634889935dd57849f92bcdf98d
7
- data.tar.gz: 768182d707c17e9f5b1f8f748f6dcaf1277e1a1bf0b68c33ed28a178464117272e8dfe294585baeac3fa1d9fb3f28945e1224b7e158fffea66bda89c5cb25dff
6
+ metadata.gz: f92cd167e57a06b93169011fe8c178e7e3f434dc4c99c4a9458fb29501dd643d6011da2b8af7adfd94dd7412e7e0cd9566f281c5ff3331815daa2aa39bc27d58
7
+ data.tar.gz: c21997ee861f81b7ad4a28b334f2f1d4258e2df54eb01865de79e0b30dae5abba1d4a6771055564a6e349d16ad07ea82e6aac57a3f35ef70fee07c9ba8f94f6a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ v0.12.0
2
+ -------------------------
3
+ - improve argument error management and allow amount equal to zero.
4
+
5
+ v0.11.0
6
+ -------------------------
7
+ - add `multi_part_interests_calculation` option.
8
+
9
+ v0.10.0
10
+ -------------------------
11
+ - fix `term_dates` options, notably with `starts_on`.
12
+
1
13
  v0.9.1
2
14
  -------------------------
3
15
  - add spec importer `scripts/convert_export_to_spec.rb {csv_url}`
data/README.md CHANGED
@@ -187,7 +187,7 @@ Default: `false`.
187
187
  The default behaviour is to use the `period` in relation to the number of months in a year (ie: for a monthly timetable annual_interests_rate * 1/12, for quarter annual_interests_rate * 3/12, etc.)
188
188
 
189
189
 
190
- `term_dates`: Optional. Implemented for `LoanCreator::Bullet`, `LoanCreator::InFine` and `LoanCreator::Linear`. Can be used if you want to implement custom due dates for terms. Terms will be compute from date to date. Must be an array with following dates.
190
+ `term_dates`: Optional. Implemented for `LoanCreator::Bullet`, `LoanCreator::InFine` and `LoanCreator::Linear`. Can be used if you want to implement custom due dates for terms. Terms will be computed from date to date. Must be an array with following dates. Must contain duration + 1 dates. The first element of the array is the first period start_date and last is the last period pay day.
191
191
 
192
192
  ## Calculation
193
193
 
@@ -24,7 +24,7 @@ module LoanCreator
24
24
  def compute_last_term(timetable)
25
25
  @crd_end_of_period = bigd('0')
26
26
  @due_interests_beginning_of_period = @due_interests_end_of_period
27
- @period_interests = @due_interests_end_of_period + compute_capitalized_interests(@due_on, timetable)
27
+ @period_interests = @due_interests_end_of_period + compute_capitalized_interests(timetable)
28
28
  @due_interests_end_of_period = 0
29
29
  @period_capital = @crd_beginning_of_period
30
30
  @total_paid_capital_end_of_period += @period_capital
@@ -32,14 +32,22 @@ module LoanCreator
32
32
  @period_amount_to_pay = @period_capital + @period_interests
33
33
  end
34
34
 
35
- def compute_capitalized_interests(due_date, timetable)
36
- computed_periodic_interests_rate = periodic_interests_rate(due_date, relative_to_date: timetable_term_dates[timetable.next_index - 1])
37
- (amount + @due_interests_beginning_of_period).mult(computed_periodic_interests_rate, BIG_DECIMAL_DIGITS)
35
+ def compute_capitalized_interests(timetable)
36
+ if multi_part_interests_calculation && term_dates? && (timetable_term_dates[timetable.current_index] + 1.year) < @due_on
37
+ multi_part_interests(
38
+ timetable_term_dates[timetable.current_index],
39
+ @due_on,
40
+ annual_interests_rate,
41
+ amount_to_capitalize
42
+ )
43
+ else
44
+ compute_period_generated_interests(periodic_interests_rate(timetable_term_dates[timetable.current_index], @due_on))
45
+ end
38
46
  end
39
47
 
40
48
  def compute_term(timetable)
41
49
  @due_interests_beginning_of_period = @due_interests_end_of_period
42
- @due_interests_end_of_period += compute_capitalized_interests(@due_on, timetable)
50
+ @due_interests_end_of_period += compute_capitalized_interests(timetable)
43
51
  end
44
52
  end
45
53
  end
@@ -1,6 +1,7 @@
1
1
  module LoanCreator
2
2
  class Common
3
3
  extend BorrowerTimetable
4
+ include TimeHelper
4
5
 
5
6
  PERIODS_IN_MONTHS = {
6
7
  month: 1,
@@ -30,9 +31,11 @@ module LoanCreator
30
31
  deferred_in_periods: 0,
31
32
  interests_start_date: nil,
32
33
  initial_values: {},
33
- realistic_durations: false
34
+ realistic_durations: false,
35
+ multi_part_interests_calculation: true
34
36
  }.freeze
35
37
 
38
+
36
39
  attr_reader *REQUIRED_ATTRIBUTES
37
40
  attr_reader *OPTIONAL_ATTRIBUTES.keys
38
41
 
@@ -47,9 +50,9 @@ module LoanCreator
47
50
  prepare_custom_term_dates if term_dates?
48
51
  end
49
52
 
50
- def periodic_interests_rate(date = nil, relative_to_date: nil)
53
+ def periodic_interests_rate(start_date, end_date)
51
54
  if realistic_durations?
52
- compute_realistic_periodic_interests_rate_percentage_for(date, relative_to_date: relative_to_date).div(100, BIG_DECIMAL_DIGITS)
55
+ compute_realistic_periodic_interests_rate(start_date, end_date, annual_interests_rate)
53
56
  else
54
57
  @periodic_interests_rate ||=
55
58
  annual_interests_rate.div(12 / PERIODS_IN_MONTHS[period], BIG_DECIMAL_DIGITS).div(100, BIG_DECIMAL_DIGITS)
@@ -101,23 +104,37 @@ module LoanCreator
101
104
 
102
105
  def set_attributes
103
106
  required_attributes.each { |k| instance_variable_set(:"@#{k}", @options.fetch(k)) }
104
- OPTIONAL_ATTRIBUTES.each { |k,v| instance_variable_set(:"@#{k}", @options.fetch(k, v)) }
107
+ OPTIONAL_ATTRIBUTES.each { |k, v| instance_variable_set(:"@#{k}", @options.fetch(k, v)) }
105
108
  end
106
109
 
107
- def validate(key, &block)
108
- raise unless block.call(instance_variable_get(:"@#{key}"))
109
- rescue => e
110
- raise ArgumentError.new([key, e.message].join(': '))
110
+ def validate(message, &block)
111
+ raise ArgumentError.new(message) unless block.call
111
112
  end
112
113
 
113
114
  def validate_attributes
114
- validate(:period) { |v| PERIODS_IN_MONTHS.keys.include?(v) } unless term_dates?
115
- validate(:amount) { |v| v.is_a?(BigDecimal) && v > 0 }
116
- validate(:annual_interests_rate) { |v| v.is_a?(BigDecimal) && v >= 0 }
117
- validate(:starts_on) { |v| v.is_a?(Date) }
118
- validate(:duration_in_periods) { |v| v.is_a?(Integer) && v > 0 }
119
- validate(:deferred_in_periods) { |v| v.is_a?(Integer) && v >= 0 && v < duration_in_periods }
120
- validate_term_dates if term_dates?
115
+ validate("amount #{@amount} must be positive") do
116
+ @amount.is_a?(BigDecimal) && @amount >= 0
117
+ end
118
+ validate("annual_interests_rate #{@annual_interests_rate} must be positive") do
119
+ @annual_interests_rate.is_a?(BigDecimal) && @annual_interests_rate >= 0
120
+ end
121
+ validate("starts_on #{@starts_on} must be a date but is a #{@starts_on.class}") do
122
+ @starts_on.is_a?(Date)
123
+ end
124
+ validate("duration_in_periods #{@duration_in_periods} must be positive") do
125
+ @duration_in_periods.is_a?(Integer) && @duration_in_periods > 0
126
+ end
127
+ validate("deferred_in_periods #{@deferred_in_periods} must be positive and less than duration_in_periods #{@duration_in_periods}") do
128
+ @deferred_in_periods.is_a?(Integer) && @deferred_in_periods >= 0 && @deferred_in_periods < duration_in_periods
129
+ end
130
+ if term_dates?
131
+ validate_term_dates
132
+ else
133
+ validate("period #{@period} must be in #{PERIODS_IN_MONTHS}") do
134
+ PERIODS_IN_MONTHS.keys.include?(@period)
135
+ end
136
+ end
137
+
121
138
  end
122
139
 
123
140
  def validate_term_dates
@@ -132,10 +149,18 @@ module LoanCreator
132
149
  def validate_initial_values
133
150
  return if initial_values.blank?
134
151
 
135
- validate(:total_paid_capital_end_of_period) { |v| v.is_a?(BigDecimal) && v >= 0 }
136
- validate(:total_paid_interests_end_of_period) { |v| v.is_a?(BigDecimal) && v >= 0 }
137
- validate(:accrued_delta_interests) { |v| v.is_a?(BigDecimal) }
138
- validate(:starting_index) { |v| v.is_a?(Integer) && v >= 0 }
152
+ validate("total_paid_capital_end_of_period #{@total_paid_capital_end_of_period} must be positive") do
153
+ @total_paid_capital_end_of_period.is_a?(BigDecimal) && @total_paid_capital_end_of_period >= 0
154
+ end
155
+ validate("total_paid_interests_end_of_period #{@total_paid_interests_end_of_period} must be positive") do
156
+ @total_paid_interests_end_of_period.is_a?(BigDecimal) && @total_paid_interests_end_of_period >= 0
157
+ end
158
+ validate("accrued_delta_interests must be a BigDecimal but is a #{@accrued_delta_interests.class}") do
159
+ @accrued_delta_interests.is_a?(BigDecimal)
160
+ end
161
+ validate("starting_index #{@starting_index} must be positive") do
162
+ @starting_index.is_a?(Integer) && @starting_index >= 0
163
+ end
139
164
  end
140
165
 
141
166
  def set_initial_values
@@ -236,43 +261,6 @@ module LoanCreator
236
261
  (interests_start_date && interests_start_date < term_zero_date) && !term_dates?
237
262
  end
238
263
 
239
- def leap_days_count(date, relative_to_date:)
240
- start_year = relative_to_date.year
241
- end_year = date.year
242
-
243
- (start_year..end_year).sum do |year|
244
- next 0 unless Date.gregorian_leap?(year)
245
-
246
- start_date =
247
- if start_year == year
248
- relative_to_date
249
- else
250
- Date.new(year - 1, 12, 31)
251
- end
252
-
253
- end_date =
254
- if end_year == year
255
- date
256
- else
257
- Date.new(year, 12, 31)
258
- end
259
-
260
- end_date - start_date
261
- end
262
- end
263
-
264
- def compute_realistic_periodic_interests_rate_percentage_for(date, relative_to_date:)
265
- total_days = date - relative_to_date
266
- leap_days = bigd(leap_days_count(date, relative_to_date: relative_to_date))
267
- non_leap_days = bigd(total_days - leap_days)
268
-
269
- annual_interests_rate.mult(
270
- leap_days.div(366, BIG_DECIMAL_DIGITS) +
271
- non_leap_days.div(365, BIG_DECIMAL_DIGITS),
272
- BIG_DECIMAL_DIGITS
273
- )
274
- end
275
-
276
264
  def realistic_durations?
277
265
  term_dates? || @realistic_durations.present?
278
266
  end
@@ -291,16 +279,21 @@ module LoanCreator
291
279
 
292
280
  def prepare_custom_term_dates
293
281
  term_dates = @options[:term_dates].each_with_index.with_object({}) do |(term_date, index), obj|
294
- obj[index + 1] = term_date
282
+ obj[index + @starting_index - 1] = term_date
295
283
  end
296
284
 
297
- term_dates[0] = starts_on
285
+ # if starting_index > 1 term_dates[0] is not set
286
+ term_dates[0] ||= starts_on
298
287
  @_timetable_term_dates = term_dates
299
288
  @realistic_durations = true
300
289
  end
301
290
 
302
291
  def compute_period_generated_interests(interests_rate)
303
- (@crd_beginning_of_period + @due_interests_beginning_of_period).mult(interests_rate, BIG_DECIMAL_DIGITS)
292
+ amount_to_capitalize.mult(interests_rate, BIG_DECIMAL_DIGITS)
293
+ end
294
+
295
+ def amount_to_capitalize
296
+ @crd_beginning_of_period + @due_interests_beginning_of_period
304
297
  end
305
298
  end
306
299
  end
@@ -23,12 +23,25 @@ module LoanCreator
23
23
  @last_period = last_period?(idx)
24
24
  @deferred_period = @index <= deferred_in_periods
25
25
  @due_on = timetable_term_dates[timetable.next_index]
26
- computed_periodic_interests_rate = periodic_interests_rate(@due_on, relative_to_date: timetable_term_dates[timetable.next_index - 1])
27
26
 
28
27
  # Reminder: CRD beginning of period = CRD end of period **of previous period**
29
28
  @crd_beginning_of_period = @crd_end_of_period
30
29
  @due_interests_beginning_of_period = @due_interests_end_of_period
31
- @period_theoric_interests = period_theoric_interests(computed_periodic_interests_rate)
30
+
31
+ @period_theoric_interests = (
32
+ # if period is more than a year
33
+ if multi_part_interests_calculation && term_dates? && (timetable_term_dates[timetable.current_index] + 1.year) < @due_on
34
+ multi_part_interests(
35
+ timetable_term_dates[timetable.current_index],
36
+ @due_on,
37
+ annual_interests_rate,
38
+ amount_to_capitalize
39
+ )
40
+ else
41
+ period_theoric_interests(periodic_interests_rate(timetable_term_dates[timetable.current_index], @due_on))
42
+ end
43
+ )
44
+
32
45
  @delta_interests = @period_theoric_interests - @period_theoric_interests.round(2)
33
46
  @accrued_delta_interests += @delta_interests
34
47
  @amount_to_add = bigd(
@@ -24,7 +24,7 @@ module LoanCreator
24
24
  @last_period = last_period?(idx)
25
25
  @deferred_period = @index <= deferred_in_periods
26
26
  @due_on = timetable_term_dates[timetable.next_index]
27
- computed_periodic_interests_rate = periodic_interests_rate(@due_on, relative_to_date: timetable_term_dates[timetable.next_index - 1])
27
+ computed_periodic_interests_rate = periodic_interests_rate(timetable_term_dates[timetable.current_index], @due_on)
28
28
 
29
29
  @crd_beginning_of_period = @crd_end_of_period
30
30
  @period_theoric_interests = period_theoric_interests(@index, computed_periodic_interests_rate)
@@ -17,8 +17,10 @@ module LoanCreator
17
17
  end
18
18
 
19
19
  def self.matches_duration(term_dates, duration_in_periods)
20
- unless term_dates.size == duration_in_periods
21
- raise ArgumentError, "the size of :term_dates (#{term_dates.size}) do not match the :duration_in_periods (#{duration_in_periods})"
20
+ unless term_dates.size == duration_in_periods + 1
21
+ error_message = "the size of :term_dates (#{term_dates.size}) do not match the :duration_in_periods (#{duration_in_periods})."
22
+ advice = "You must pass the previous term date (or start_on if starting_index == 1) as the first term date"
23
+ raise ArgumentError, "#{error_message} #{advice}"
22
24
  end
23
25
  end
24
26
 
@@ -64,7 +66,7 @@ module LoanCreator
64
66
  raise ArgumentError, "term dates can't be more than 1 year apart. #{error_description}"
65
67
  end
66
68
  end
67
- end
69
+ end
68
70
 
69
71
  def self.bullet?(loan_class)
70
72
  loan_class == "LoanCreator::Bullet"
@@ -0,0 +1,62 @@
1
+ module LoanCreator
2
+ module TimeHelper
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ def leap_days_count(start_date, end_date)
7
+ start_year = start_date.year
8
+ # mostly no op but allows to skip one iteration if end date is january 1st
9
+ end_year = (end_date - 1.day).year
10
+
11
+ (start_year..end_year).sum do |year|
12
+ next 0 unless Date.gregorian_leap?(year)
13
+
14
+ current_start_date =
15
+ if start_year == year
16
+ start_date
17
+ else
18
+ Date.new(year, 1, 1)
19
+ end
20
+
21
+ current_end_date =
22
+ if end_year == year
23
+ end_date
24
+ else
25
+ Date.new(year + 1, 1, 1)
26
+ end
27
+
28
+ current_end_date - current_start_date
29
+ end
30
+ end
31
+
32
+ def compute_realistic_periodic_interests_rate(start_date, end_date, annual_interests_rate)
33
+ total_days = end_date - start_date
34
+ leap_days = bigd(leap_days_count(start_date, end_date))
35
+ non_leap_days = bigd(total_days - leap_days)
36
+
37
+ annual_interests_rate.mult(
38
+ leap_days.div(366, BIG_DECIMAL_DIGITS) +
39
+ non_leap_days.div(365, BIG_DECIMAL_DIGITS),
40
+ BIG_DECIMAL_DIGITS
41
+ ).div(100, BIG_DECIMAL_DIGITS)
42
+ end
43
+
44
+ # for terms spanning more than a year,
45
+ # we capitalize each years until the last one which behaves normally
46
+ def multi_part_interests(start_date, end_date, annual_interests_rate, amount_to_capitalize)
47
+ duration_in_days = end_date - start_date
48
+ leap_days = bigd(leap_days_count(start_date, end_date))
49
+ non_leap_days = bigd(duration_in_days - leap_days)
50
+
51
+ ratio = non_leap_days.div(365, BIG_DECIMAL_DIGITS) + leap_days.div(366, BIG_DECIMAL_DIGITS)
52
+ full_years, year_part = ratio.divmod(1)
53
+ rate = annual_interests_rate.div(100, BIG_DECIMAL_DIGITS)
54
+
55
+ total = amount_to_capitalize.mult((1 + rate)**full_years, BIG_DECIMAL_DIGITS)
56
+ .mult(1 + rate * year_part, BIG_DECIMAL_DIGITS)
57
+
58
+ total - amount_to_capitalize
59
+ end
60
+ end
61
+ end
62
+ end
@@ -40,5 +40,9 @@ module LoanCreator
40
40
  def next_index
41
41
  @current_index.nil? ? @starting_index : @current_index + 1
42
42
  end
43
+
44
+ def current_index
45
+ @current_index.nil? ? @starting_index - 1 : @current_index
46
+ end
43
47
  end
44
48
  end
@@ -33,7 +33,7 @@ module LoanCreator
33
33
  end
34
34
 
35
35
  def compute_interests(due_date, timetable)
36
- computed_periodic_interests_rate = periodic_interests_rate(due_date, relative_to_date: timetable_term_dates[timetable.next_index - 1])
36
+ computed_periodic_interests_rate = periodic_interests_rate(timetable_term_dates[timetable.current_index], due_date)
37
37
  amount.mult(bigd(computed_periodic_interests_rate), BIG_DECIMAL_DIGITS)
38
38
  end
39
39
 
@@ -1,3 +1,3 @@
1
1
  module LoanCreator
2
- VERSION = '0.9.1'.freeze
2
+ VERSION = '0.12.0'.freeze
3
3
  end
data/lib/loan_creator.rb CHANGED
@@ -8,6 +8,7 @@ module LoanCreator
8
8
  BIG_DECIMAL_DIGITS = 14
9
9
 
10
10
  autoload :ExcelFormulas, 'loan_creator/excel_formulas'
11
+ autoload :TimeHelper, 'loan_creator/time_helper'
11
12
  autoload :BorrowerTimetable, 'loan_creator/borrower_timetable'
12
13
  autoload :Common, 'loan_creator/common'
13
14
  autoload :Standard, 'loan_creator/standard'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: loan_creator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thibaulth
@@ -12,7 +12,7 @@ authors:
12
12
  autorequire:
13
13
  bindir: exe
14
14
  cert_chain: []
15
- date: 2021-05-11 00:00:00.000000000 Z
15
+ date: 2022-07-12 00:00:00.000000000 Z
16
16
  dependencies:
17
17
  - !ruby/object:Gem::Dependency
18
18
  name: bundler
@@ -178,6 +178,7 @@ files:
178
178
  - lib/loan_creator/standard.rb
179
179
  - lib/loan_creator/term.rb
180
180
  - lib/loan_creator/term_dates_validator.rb
181
+ - lib/loan_creator/time_helper.rb
181
182
  - lib/loan_creator/timetable.rb
182
183
  - lib/loan_creator/uncapitalized_bullet.rb
183
184
  - lib/loan_creator/version.rb