loan_creator 0.7.1 → 0.9.1

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: 72a0b25211b0e4cff4c6089ed26ecde7f792eec8404ac58d459e769dcd275c87
4
- data.tar.gz: ea1836c498d837e62a8cfa389fd6d0041c2a6f71861c2a062ecc780980692170
3
+ metadata.gz: e207b2a1a30e9f07b773f9d3071c8df1cb815e17602661efb72d4315ead65dba
4
+ data.tar.gz: c6760d02448a8a9bd6f40fa992aa09f1d716a06b94e2ce5afa8b54eddfeeddf6
5
5
  SHA512:
6
- metadata.gz: b45dd0f8944759ee36d58fb746522a7b2727ba8182a845b8387ff871c13df2494707ab7a7e03f8cd64f6b68d7495a25b1634ff980f81af57f31ee6f870374fe9
7
- data.tar.gz: b5a70c0ffc639f8afe638f47a423170eddfa0557e44936366895f14a1db9608a2657442dcfcdf2190576416bb215bb1e73776e6a6727304b7e1b4fbb500d2041
6
+ metadata.gz: 75d786838e61c1f342da8a3ebf3e0264e42f0e80909bc22ffe63f5c76acf4a1c36e1130ebcbcb4f8c72d87f7a99735c8e3d70f634889935dd57849f92bcdf98d
7
+ data.tar.gz: 768182d707c17e9f5b1f8f748f6dcaf1277e1a1bf0b68c33ed28a178464117272e8dfe294585baeac3fa1d9fb3f28945e1224b7e158fffea66bda89c5cb25dff
data/CHANGELOG.md CHANGED
@@ -1,3 +1,38 @@
1
+ v0.9.1
2
+ -------------------------
3
+ - add spec importer `scripts/convert_export_to_spec.rb {csv_url}`
4
+ - fix period interests rate for realistic duration period overlapping leap & non leap years
5
+
6
+ v0.9.0
7
+ -------------------------
8
+ - add possibility to pass the argument `term_dates` for `LoanCreator::Bullet`, `LoanCreator::InFine` and `LoanCreator::Linear`
9
+ - `term_dates` option is based on `realistic_durations` and allow to compute terms custom date to custom date
10
+
11
+ v0.8.2
12
+ -------------------------
13
+
14
+ - manage initial value `due_interests` for Linear and InFine loan types
15
+
16
+ v0.8.1
17
+ -------------------------
18
+ - add `realistic_durations` option
19
+
20
+ v0.8.0
21
+ -------------------------
22
+
23
+ #### Feature
24
+ - due interests to date are now store into `due_interests_` columns for `LoanCreator::UncapitalizedBullet`
25
+
26
+ #### Bugfix
27
+ - fix `initial_values` not taking `paid_capital` and `paid_interests` into account for `LoanCreator::UncapitalizedBullet`
28
+ and `LoanCreator::Bullet`
29
+
30
+ #### Breaking changes
31
+ - rename columns `capitalized_interests_beginning_of_period` and `capitalized_interests_end_of_period` for
32
+ `due_interests_beginning_of_period` and `due_interests_end_of_period`
33
+ - rename `capitalized_interests` option in `:inital_values` into `due_interests`
34
+
35
+
1
36
  v0.7.1
2
37
  -------------------------
3
38
 
data/README.md CHANGED
@@ -44,9 +44,10 @@ Each instance of one of the previous classes has the following attributes:
44
44
  :annual_interests_rate
45
45
  :starts_at
46
46
  :duration_in_periods
47
- :deferred_in_periods (default to zero)
47
+ :deferred_in_periods (defaults to zero)
48
48
  :interests_start_date (optional)
49
49
  :initial_values (to generate a timetable from a previous term or at a given state)
50
+ :realistic_durations (optional, defaults to false)
50
51
  ```
51
52
 
52
53
  Initial values must be a hash with specific keys, like so:
@@ -57,7 +58,7 @@ Initial values must be a hash with specific keys, like so:
57
58
  paid_interests: 11000.0,
58
59
  accrued_delta_interests: 0,
59
60
  starting_index: 2,
60
- capitalized_interests: 0
61
+ due_interests: 0
61
62
  }
62
63
  ```
63
64
 
@@ -102,6 +103,9 @@ There is also a `LoanCreator::Timetable` class dedicated to record the data of t
102
103
 
103
104
  # Amount to pay this term
104
105
  :period_amount_to_pay
106
+
107
+ # Whether or not to use the real number of days in each month
108
+ :realistic_durations
105
109
  ```
106
110
 
107
111
  `#periodic_interests_rate` renders a precise calculation of the loan's periodic interests rate based on two inputs: `#annual_interests_rate` and `#period`.
@@ -148,9 +152,11 @@ Capital share shall be repaid in full at loan's end.
148
152
  `Bullet` loan generates terms where terms' payments are zero. \
149
153
  Interests are capitalized, i.e. added to the borrowed capital on each term.\
150
154
  Capital share shall be repaid in full and all interests paid at loan's end.
155
+ N.b.: Due capitalized interests to date are stored into terms under `due_intesrests_` columns
151
156
 
152
157
  `UncapitalizedBullet` same as bullet, the only difference is the interests\
153
158
  are NOT capitalized.
159
+ N.b.: Due interests to date are stored into terms under `due_intesrests_` columns
154
160
 
155
161
  There is no deferred time for `InFine` and `Bullet` loans as it would be equivalent to increasing loan's duration.
156
162
 
@@ -174,6 +180,15 @@ additional term with only interests for the time difference.
174
180
  For example, with a `start_at` in january 2020 and a `interests_start_date` in october 2019, the timetable will include a
175
181
  first term corresponding to 3 months of interests.
176
182
 
183
+ `realistic_durations`: Optional. A boolean that specifies whether or not to use the real number of days in each month when calculating the periodic interests rate (ie: for a period between January 1st 2021 and February 1st 2021 the period rate is annual_interests_rate * 31/355).
184
+ Note that leap years are taken into account (ie: for a period between January 1st 2020 and February 1st 2020 the period rate is annual_interests_rate * 31/356). Also, an overlaping period
185
+ take each parts into account (ie: for a period between December 1st 2020 and February 1st 2021, the period rate is (annual_interests_rate * (31/355) + annual_interests_rate * (31/356)))
186
+ Default: `false`.
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
+
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.
191
+
177
192
  ## Calculation
178
193
 
179
194
  An excel simulator for standard case can be found [here](CapSens_Loan.xlsx).
data/lib/loan_creator.rb CHANGED
@@ -7,14 +7,15 @@ require 'loan_creator/version'
7
7
  module LoanCreator
8
8
  BIG_DECIMAL_DIGITS = 14
9
9
 
10
- autoload :ExcelFormulas, 'loan_creator/excel_formulas'
11
- autoload :BorrowerTimetable, 'loan_creator/borrower_timetable'
12
- autoload :Common, 'loan_creator/common'
13
- autoload :Standard, 'loan_creator/standard'
14
- autoload :Linear, 'loan_creator/linear'
15
- autoload :InFine, 'loan_creator/in_fine'
16
- autoload :Bullet, 'loan_creator/bullet'
17
- autoload :Timetable, 'loan_creator/timetable'
18
- autoload :Term, 'loan_creator/term'
19
- autoload :UncapitalizedBullet, 'loan_creator/uncapitalized_bullet'
10
+ autoload :ExcelFormulas, 'loan_creator/excel_formulas'
11
+ autoload :BorrowerTimetable, 'loan_creator/borrower_timetable'
12
+ autoload :Common, 'loan_creator/common'
13
+ autoload :Standard, 'loan_creator/standard'
14
+ autoload :Linear, 'loan_creator/linear'
15
+ autoload :InFine, 'loan_creator/in_fine'
16
+ autoload :Bullet, 'loan_creator/bullet'
17
+ autoload :Timetable, 'loan_creator/timetable'
18
+ autoload :Term, 'loan_creator/term'
19
+ autoload :UncapitalizedBullet, 'loan_creator/uncapitalized_bullet'
20
+ autoload :TermDatesValidator, 'loan_creator/term_dates_validator'
20
21
  end
@@ -7,8 +7,8 @@ module LoanCreator
7
7
  :period_capital,
8
8
  :total_paid_capital_end_of_period,
9
9
  :total_paid_interests_end_of_period,
10
- :capitalized_interests_beginning_of_period,
11
- :capitalized_interests_end_of_period,
10
+ :due_interests_beginning_of_period,
11
+ :due_interests_end_of_period,
12
12
  :period_amount_to_pay
13
13
  ].freeze
14
14
 
@@ -21,8 +21,7 @@ module LoanCreator
21
21
 
22
22
  borrower_timetable = LoanCreator::Timetable.new(
23
23
  starting_index: lenders_timetables.first.starting_index,
24
- starts_on: lenders_timetables.first.starts_on,
25
- period: lenders_timetables.first.period
24
+ loan: lenders_timetables.first.loan
26
25
  )
27
26
 
28
27
  # Borrower timetable is not concerned with computation-related value (delta, etc.),
@@ -7,33 +7,39 @@ module LoanCreator
7
7
  reset_current_term
8
8
  @crd_beginning_of_period = amount
9
9
  @crd_end_of_period = amount
10
- (duration_in_periods - 1).times { |period| compute_term(timetable) }
11
- compute_last_term
12
- timetable << current_term
10
+
11
+ duration_in_periods.times { |idx| timetable << compute_current_term(idx, timetable) }
12
+
13
13
  timetable
14
14
  end
15
15
 
16
16
  private
17
17
 
18
- def compute_last_term
19
- @crd_end_of_period = bigd('0')
20
- @capitalized_interests_beginning_of_period = @capitalized_interests_end_of_period
21
- @period_interests = @capitalized_interests_end_of_period + compute_capitalized_interests
22
- @capitalized_interests_end_of_period = 0
23
- @period_capital = @crd_beginning_of_period
24
- @total_paid_capital_end_of_period = @period_capital
25
- @total_paid_interests_end_of_period = @period_interests
26
- @period_amount_to_pay = @period_capital + @period_interests
18
+ def compute_current_term(idx, timetable)
19
+ @due_on = timetable_term_dates[timetable.next_index]
20
+ last_period?(idx) ? compute_last_term(timetable) : compute_term(timetable)
21
+ current_term
22
+ end
23
+
24
+ def compute_last_term(timetable)
25
+ @crd_end_of_period = bigd('0')
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)
28
+ @due_interests_end_of_period = 0
29
+ @period_capital = @crd_beginning_of_period
30
+ @total_paid_capital_end_of_period += @period_capital
31
+ @total_paid_interests_end_of_period += @period_interests
32
+ @period_amount_to_pay = @period_capital + @period_interests
27
33
  end
28
34
 
29
- def compute_capitalized_interests
30
- (amount + @capitalized_interests_beginning_of_period).mult(periodic_interests_rate, BIG_DECIMAL_DIGITS)
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)
31
38
  end
32
39
 
33
40
  def compute_term(timetable)
34
- @capitalized_interests_beginning_of_period = @capitalized_interests_end_of_period
35
- @capitalized_interests_end_of_period += compute_capitalized_interests
36
- timetable << current_term
41
+ @due_interests_beginning_of_period = @due_interests_end_of_period
42
+ @due_interests_end_of_period += compute_capitalized_interests(@due_on, timetable)
37
43
  end
38
44
  end
39
45
  end
@@ -17,11 +17,20 @@ module LoanCreator
17
17
  :duration_in_periods
18
18
  ].freeze
19
19
 
20
+ REQUIRED_ATTRIBUTES_TERM_DATES = [
21
+ :amount,
22
+ :annual_interests_rate,
23
+ :starts_on,
24
+ :duration_in_periods,
25
+ :term_dates
26
+ ].freeze
27
+
20
28
  OPTIONAL_ATTRIBUTES = {
21
29
  # attribute: default_value
22
30
  deferred_in_periods: 0,
23
31
  interests_start_date: nil,
24
- initial_values: {}
32
+ initial_values: {},
33
+ realistic_durations: false
25
34
  }.freeze
26
35
 
27
36
  attr_reader *REQUIRED_ATTRIBUTES
@@ -35,16 +44,29 @@ module LoanCreator
35
44
  validate_attributes
36
45
  set_initial_values
37
46
  validate_initial_values
47
+ prepare_custom_term_dates if term_dates?
38
48
  end
39
49
 
40
- def periodic_interests_rate_percentage
41
- @periodic_interests_rate_percentage ||=
42
- annual_interests_rate.div(12 / PERIODS_IN_MONTHS[period], BIG_DECIMAL_DIGITS)
50
+ def periodic_interests_rate(date = nil, relative_to_date: nil)
51
+ if realistic_durations?
52
+ compute_realistic_periodic_interests_rate_percentage_for(date, relative_to_date: relative_to_date).div(100, BIG_DECIMAL_DIGITS)
53
+ else
54
+ @periodic_interests_rate ||=
55
+ annual_interests_rate.div(12 / PERIODS_IN_MONTHS[period], BIG_DECIMAL_DIGITS).div(100, BIG_DECIMAL_DIGITS)
56
+ end
43
57
  end
44
58
 
45
- def periodic_interests_rate
46
- @periodic_interests_rate ||=
47
- periodic_interests_rate_percentage.div(100, BIG_DECIMAL_DIGITS)
59
+ def timetable_term_dates
60
+ @_timetable_term_dates ||= Hash.new do |dates, index|
61
+ dates[index] =
62
+ if index < 1
63
+ dates[index + 1].advance(months: -PERIODS_IN_MONTHS.fetch(period))
64
+ elsif index == 1
65
+ starts_on
66
+ else
67
+ starts_on.advance(months: PERIODS_IN_MONTHS.fetch(period) * (index - 1))
68
+ end
69
+ end
48
70
  end
49
71
 
50
72
  def lender_timetable
@@ -62,35 +84,49 @@ module LoanCreator
62
84
  private
63
85
 
64
86
  def require_attributes
65
- REQUIRED_ATTRIBUTES.each { |k| raise ArgumentError.new(k) unless @options.fetch(k, nil) }
87
+ required_attributes.each { |k| raise ArgumentError.new(k) unless @options.fetch(k, nil) }
66
88
  end
67
89
 
68
90
  def reinterpret_attributes
69
- @options[:period] = @options[:period].to_sym
91
+ @options[:period] = @options[:period].to_sym unless term_dates?
70
92
  @options[:amount] = bigd(@options[:amount])
71
93
  @options[:annual_interests_rate] = bigd(@options[:annual_interests_rate])
72
94
  @options[:starts_on] = Date.parse(@options[:starts_on]) if @options[:starts_on].is_a?(String)
73
95
  @options[:interests_start_date] = Date.parse(@options[:interests_start_date]) if @options[:interests_start_date].is_a?(String)
96
+
97
+ if term_dates?
98
+ @options[:term_dates].map!{ |term_date| Date.parse(term_date.to_s) }
99
+ end
74
100
  end
75
101
 
76
102
  def set_attributes
77
- REQUIRED_ATTRIBUTES.each { |k| instance_variable_set(:"@#{k}", @options.fetch(k)) }
103
+ required_attributes.each { |k| instance_variable_set(:"@#{k}", @options.fetch(k)) }
78
104
  OPTIONAL_ATTRIBUTES.each { |k,v| instance_variable_set(:"@#{k}", @options.fetch(k, v)) }
79
105
  end
80
106
 
81
107
  def validate(key, &block)
82
108
  raise unless block.call(instance_variable_get(:"@#{key}"))
83
- rescue
84
- raise ArgumentError.new(key)
109
+ rescue => e
110
+ raise ArgumentError.new([key, e.message].join(': '))
85
111
  end
86
112
 
87
113
  def validate_attributes
88
- validate(:period) { |v| PERIODS_IN_MONTHS.keys.include?(v) }
114
+ validate(:period) { |v| PERIODS_IN_MONTHS.keys.include?(v) } unless term_dates?
89
115
  validate(:amount) { |v| v.is_a?(BigDecimal) && v > 0 }
90
116
  validate(:annual_interests_rate) { |v| v.is_a?(BigDecimal) && v >= 0 }
91
117
  validate(:starts_on) { |v| v.is_a?(Date) }
92
118
  validate(:duration_in_periods) { |v| v.is_a?(Integer) && v > 0 }
93
119
  validate(:deferred_in_periods) { |v| v.is_a?(Integer) && v >= 0 && v < duration_in_periods }
120
+ validate_term_dates if term_dates?
121
+ end
122
+
123
+ def validate_term_dates
124
+ TermDatesValidator.call(
125
+ term_dates: @options[:term_dates],
126
+ duration_in_periods: @options[:duration_in_periods],
127
+ interests_start_date: @options[:interests_start_date],
128
+ loan_class: self.class.name
129
+ )
94
130
  end
95
131
 
96
132
  def validate_initial_values
@@ -103,59 +139,57 @@ module LoanCreator
103
139
  end
104
140
 
105
141
  def set_initial_values
106
- @starting_index = initial_values[:starting_index] || 1
142
+ @starting_index = initial_values[:starting_index] || 1
143
+ @initial_due_interests = bigd(initial_values[:due_interests] || 0)
107
144
 
108
145
  return if initial_values.blank?
109
146
 
110
147
  (@total_paid_capital_end_of_period = bigd(initial_values[:paid_capital]))
111
148
  (@total_paid_interests_end_of_period = bigd(initial_values[:paid_interests]))
112
149
  (@accrued_delta_interests = bigd(initial_values[:accrued_delta_interests]))
113
- if self.is_a?(LoanCreator::Bullet)
114
- (@capitalized_interests_beginning_of_period = bigd(initial_values[:capitalized_interests] || 0))
115
- end
150
+ (@due_interests_beginning_of_period = bigd(initial_values[:due_interests] || 0))
116
151
  end
117
152
 
118
153
  def reset_current_term
119
- @accrued_delta_interests ||= bigd('0')
120
- @total_paid_capital_end_of_period ||= bigd('0')
154
+ @accrued_delta_interests ||= bigd('0')
155
+ @total_paid_capital_end_of_period ||= bigd('0')
121
156
  @total_paid_interests_end_of_period ||= bigd('0')
122
- @capitalized_interests_beginning_of_period ||= bigd('0')
123
- @crd_beginning_of_period = bigd('0')
124
- @crd_end_of_period = bigd('0')
125
- @period_theoric_interests = bigd('0')
126
- @capitalized_interests_end_of_period = @capitalized_interests_beginning_of_period
127
- @delta_interests = bigd('0')
128
- @amount_to_add = bigd('0')
129
- @period_interests = bigd('0')
130
- @period_capital = bigd('0')
131
- @period_amount_to_pay = bigd('0')
132
- @due_on = nil
157
+ @due_interests_beginning_of_period ||= bigd('0')
158
+ @crd_beginning_of_period = bigd('0')
159
+ @crd_end_of_period = bigd('0')
160
+ @period_theoric_interests = bigd('0')
161
+ @due_interests_end_of_period = @due_interests_beginning_of_period
162
+ @delta_interests = bigd('0')
163
+ @amount_to_add = bigd('0')
164
+ @period_interests = bigd('0')
165
+ @period_capital = bigd('0')
166
+ @period_amount_to_pay = bigd('0')
167
+ @due_on = nil
133
168
  end
134
169
 
135
170
  def current_term
136
171
  LoanCreator::Term.new(
137
- crd_beginning_of_period: @crd_beginning_of_period,
138
- crd_end_of_period: @crd_end_of_period,
139
- period_theoric_interests: @period_theoric_interests,
140
- delta_interests: @delta_interests,
141
- accrued_delta_interests: @accrued_delta_interests,
142
- capitalized_interests_beginning_of_period: @capitalized_interests_beginning_of_period,
143
- capitalized_interests_end_of_period: @capitalized_interests_end_of_period,
144
- amount_to_add: @amount_to_add,
145
- period_interests: @period_interests,
146
- period_capital: @period_capital,
147
- total_paid_capital_end_of_period: @total_paid_capital_end_of_period,
148
- total_paid_interests_end_of_period: @total_paid_interests_end_of_period,
149
- period_amount_to_pay: @period_amount_to_pay,
150
- due_on: @due_on,
151
- index: compute_index
172
+ crd_beginning_of_period: @crd_beginning_of_period,
173
+ crd_end_of_period: @crd_end_of_period,
174
+ period_theoric_interests: @period_theoric_interests,
175
+ delta_interests: @delta_interests,
176
+ accrued_delta_interests: @accrued_delta_interests,
177
+ due_interests_beginning_of_period: @due_interests_beginning_of_period,
178
+ due_interests_end_of_period: @due_interests_end_of_period,
179
+ amount_to_add: @amount_to_add,
180
+ period_interests: @period_interests,
181
+ period_capital: @period_capital,
182
+ total_paid_capital_end_of_period: @total_paid_capital_end_of_period,
183
+ total_paid_interests_end_of_period: @total_paid_interests_end_of_period,
184
+ period_amount_to_pay: @period_amount_to_pay,
185
+ due_on: @due_on,
186
+ index: compute_index
152
187
  )
153
188
  end
154
189
 
155
190
  def new_timetable
156
191
  LoanCreator::Timetable.new(
157
- starts_on: starts_on,
158
- period: period,
192
+ loan: self,
159
193
  interests_start_date: interests_start_date,
160
194
  starting_index: @starting_index
161
195
  )
@@ -165,6 +199,10 @@ module LoanCreator
165
199
  @index ? (@starting_index + @index - 1) : nil
166
200
  end
167
201
 
202
+ def last_period?(idx)
203
+ idx == (duration_in_periods - 1)
204
+ end
205
+
168
206
  def compute_term_zero
169
207
  @crd_beginning_of_period = @crd_end_of_period
170
208
  @period_theoric_interests = term_zero_interests
@@ -174,6 +212,7 @@ module LoanCreator
174
212
  @total_paid_interests_end_of_period += @period_interests
175
213
  @period_amount_to_pay = @period_interests
176
214
  @index = 0
215
+ @due_on = timetable_term_dates[0]
177
216
  end
178
217
 
179
218
  def term_zero_interests
@@ -194,7 +233,74 @@ module LoanCreator
194
233
  end
195
234
 
196
235
  def term_zero?
197
- interests_start_date && interests_start_date < term_zero_date
236
+ (interests_start_date && interests_start_date < term_zero_date) && !term_dates?
237
+ end
238
+
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
+ def realistic_durations?
277
+ term_dates? || @realistic_durations.present?
278
+ end
279
+
280
+ def required_attributes
281
+ if term_dates?
282
+ REQUIRED_ATTRIBUTES_TERM_DATES
283
+ else
284
+ REQUIRED_ATTRIBUTES
285
+ end
286
+ end
287
+
288
+ def term_dates?
289
+ @options[:term_dates].present?
290
+ end
291
+
292
+ def prepare_custom_term_dates
293
+ term_dates = @options[:term_dates].each_with_index.with_object({}) do |(term_date, index), obj|
294
+ obj[index + 1] = term_date
295
+ end
296
+
297
+ term_dates[0] = starts_on
298
+ @_timetable_term_dates = term_dates
299
+ @realistic_durations = true
300
+ end
301
+
302
+ def compute_period_generated_interests(interests_rate)
303
+ (@crd_beginning_of_period + @due_interests_beginning_of_period).mult(interests_rate, BIG_DECIMAL_DIGITS)
198
304
  end
199
305
  end
200
306
  end
@@ -11,26 +11,24 @@ module LoanCreator
11
11
  timetable << current_term
12
12
  end
13
13
 
14
- duration_in_periods.times do |idx|
15
- @last_period = last_period?(idx)
16
- @deferred_period = idx < deferred_in_periods
17
- compute_current_term(idx)
18
- timetable << current_term
19
- end
14
+ duration_in_periods.times { |idx| timetable << compute_current_term(idx, timetable) }
20
15
 
21
16
  timetable
22
17
  end
23
18
 
24
19
  private
25
20
 
26
- def last_period?(idx)
27
- idx == (duration_in_periods - 1)
28
- end
21
+ def compute_current_term(idx, timetable)
22
+ @index = idx + 1
23
+ @last_period = last_period?(idx)
24
+ @deferred_period = @index <= deferred_in_periods
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])
29
27
 
30
- def compute_current_term(idx)
31
28
  # Reminder: CRD beginning of period = CRD end of period **of previous period**
32
29
  @crd_beginning_of_period = @crd_end_of_period
33
- @period_theoric_interests = @crd_beginning_of_period * periodic_interests_rate
30
+ @due_interests_beginning_of_period = @due_interests_end_of_period
31
+ @period_theoric_interests = period_theoric_interests(computed_periodic_interests_rate)
34
32
  @delta_interests = @period_theoric_interests - @period_theoric_interests.round(2)
35
33
  @accrued_delta_interests += @delta_interests
36
34
  @amount_to_add = bigd(
@@ -49,8 +47,17 @@ module LoanCreator
49
47
  @total_paid_interests_end_of_period += @period_interests
50
48
  @period_amount_to_pay = @period_interests + @period_capital
51
49
  @crd_end_of_period -= @period_capital
52
- @due_on = nil
53
- @index = idx + 1
50
+ @due_interests_end_of_period -= reimbursed_due_interests
51
+
52
+ current_term
53
+ end
54
+
55
+ def period_theoric_interests(computed_periodic_interests_rate)
56
+ if @due_interests_beginning_of_period > 0
57
+ reimbursed_due_interests + compute_period_generated_interests(computed_periodic_interests_rate)
58
+ else
59
+ compute_period_generated_interests(computed_periodic_interests_rate)
60
+ end
54
61
  end
55
62
 
56
63
  def period_capital
@@ -58,9 +65,26 @@ module LoanCreator
58
65
  @crd_beginning_of_period
59
66
  elsif @deferred_period
60
67
  bigd(0)
68
+ elsif @due_interests_beginning_of_period > 0
69
+ compute_period_capital - reimbursed_due_interests
70
+ else
71
+ compute_period_capital
72
+ end
73
+ end
74
+
75
+ def reimbursed_due_interests
76
+ if @deferred_period
77
+ bigd(0)
61
78
  else
62
- (amount / (duration_in_periods - deferred_in_periods)).round(2)
79
+ [
80
+ @due_interests_beginning_of_period,
81
+ compute_period_capital
82
+ ].min
63
83
  end
64
84
  end
85
+
86
+ def compute_period_capital
87
+ ((amount + @initial_due_interests) / (duration_in_periods - deferred_in_periods)).round(2)
88
+ end
65
89
  end
66
90
  end
@@ -12,24 +12,22 @@ module LoanCreator
12
12
  timetable << current_term
13
13
  end
14
14
 
15
- duration_in_periods.times do |idx|
16
- @last_period = last_period?(idx)
17
- @deferred_period = idx < deferred_in_periods
18
- compute_current_term(idx)
19
- timetable << current_term
20
- end
15
+ duration_in_periods.times { |idx| timetable << compute_current_term(idx, timetable) }
16
+
21
17
  timetable
22
18
  end
23
19
 
24
20
  private
25
21
 
26
- def last_period?(idx)
27
- idx == (duration_in_periods - 1)
28
- end
22
+ def compute_current_term(idx, timetable)
23
+ @index = idx + 1
24
+ @last_period = last_period?(idx)
25
+ @deferred_period = @index <= deferred_in_periods
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])
29
28
 
30
- def compute_current_term(idx)
31
29
  @crd_beginning_of_period = @crd_end_of_period
32
- @period_theoric_interests = period_theoric_interests(idx)
30
+ @period_theoric_interests = period_theoric_interests(@index, computed_periodic_interests_rate)
33
31
  @delta_interests = @period_theoric_interests - @period_theoric_interests.round(2)
34
32
  @accrued_delta_interests += @delta_interests
35
33
  @amount_to_add = bigd(
@@ -43,37 +41,37 @@ module LoanCreator
43
41
  )
44
42
  @accrued_delta_interests -= @amount_to_add
45
43
  @period_interests = @period_theoric_interests.round(2) + @amount_to_add
46
- @period_capital = period_capital(idx)
44
+ @period_capital = period_capital(@index, computed_periodic_interests_rate)
47
45
  @total_paid_capital_end_of_period += @period_capital
48
46
  @total_paid_interests_end_of_period += @period_interests
49
47
  @period_amount_to_pay = @period_interests + @period_capital
50
48
  @crd_end_of_period -= @period_capital
51
- @due_on = nil
52
- @index = idx + 1
49
+
50
+ current_term
53
51
  end
54
52
 
55
- def period_theoric_interests(idx)
53
+ def period_theoric_interests(idx, computed_periodic_interests_rate)
56
54
  if @deferred_period
57
- @crd_beginning_of_period * periodic_interests_rate
55
+ @crd_beginning_of_period * computed_periodic_interests_rate
58
56
  else
59
57
  -ipmt(
60
- periodic_interests_rate,
61
- (idx + 1) - deferred_in_periods,
58
+ computed_periodic_interests_rate,
59
+ idx - deferred_in_periods,
62
60
  duration_in_periods - deferred_in_periods,
63
61
  amount
64
62
  )
65
63
  end
66
64
  end
67
65
 
68
- def period_capital(idx)
66
+ def period_capital(idx, computed_periodic_interests_rate)
69
67
  if @last_period
70
68
  @crd_beginning_of_period
71
69
  elsif @deferred_period
72
70
  bigd(0)
73
71
  else
74
72
  -ppmt(
75
- periodic_interests_rate,
76
- (idx + 1) - deferred_in_periods,
73
+ computed_periodic_interests_rate,
74
+ idx - deferred_in_periods,
77
75
  duration_in_periods - deferred_in_periods,
78
76
  amount
79
77
  ).round(2)
@@ -17,11 +17,11 @@ module LoanCreator
17
17
  # Accrued interests' delta
18
18
  :accrued_delta_interests,
19
19
 
20
- # Capitalized interests at the beginning of the term (Bullet only)
21
- :capitalized_interests_beginning_of_period,
20
+ # Due interests at the beginning of the term (Bullet and UncapitalizedBullet only)
21
+ :due_interests_beginning_of_period,
22
22
 
23
- # Capitalized interests at the end of the term (Bullet only)
24
- :capitalized_interests_end_of_period,
23
+ # Due interests at the end of the term (Bullet and UncapitalizedBullet only)
24
+ :due_interests_end_of_period,
25
25
 
26
26
  # Adjustment of -0.01, 0 or +0.01 cent depending on accrued_delta_interests
27
27
  :amount_to_add,
@@ -0,0 +1,73 @@
1
+ module LoanCreator
2
+ module TermDatesValidator
3
+ def self.call(term_dates:, duration_in_periods:, interests_start_date:, loan_class:)
4
+ is_array(term_dates)
5
+ matches_duration(term_dates, duration_in_periods)
6
+ interests_start_date_present(interests_start_date)
7
+ coherent_dates_for_non_bullet(term_dates)
8
+ coherent_dates_for_bullet(term_dates) if bullet?(loan_class)
9
+ end
10
+
11
+ private
12
+
13
+ def self.is_array(term_dates)
14
+ unless term_dates.is_a?(Array)
15
+ raise TypeError, 'the :term_dates option must be an Array'
16
+ end
17
+ end
18
+
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})"
22
+ end
23
+ end
24
+
25
+ def self.interests_start_date_present(interests_start_date)
26
+ if interests_start_date.present?
27
+ raise ArgumentError, ":interests_start_date is no compatible with :term_dates"
28
+ end
29
+ end
30
+
31
+ def self.coherent_dates_for_non_bullet(term_dates)
32
+ term_dates.each_with_index do |term_date, index|
33
+ next if index.zero?
34
+
35
+ previous_term_date = term_dates[index - 1]
36
+
37
+ unless term_date > previous_term_date
38
+ previous_term_date_description =
39
+ ":term_dates[#{index - 1}] (#{term_dates[index - 1].strftime('%Y-%m-%d')})"
40
+
41
+ error_message = "#{previous_term_date_description} must be before :term_dates[#{index}] (#{term_date.strftime('%Y-%m-%d')})"
42
+
43
+ raise ArgumentError, error_message
44
+ end
45
+ end
46
+ end
47
+
48
+ def self.coherent_dates_for_bullet(term_dates)
49
+ term_dates.each_with_index do |term_date, index|
50
+ next if index.zero?
51
+
52
+ days_in_year = 365
53
+ days_in_year += 1 if term_date.leap?
54
+
55
+ previous_term_date = term_dates[index - 1]
56
+ days_between = (term_date - previous_term_date).to_i.abs
57
+
58
+ if days_between > days_in_year
59
+ previous_term_date_description =
60
+ ":term_dates[#{index - 1}] (#{term_dates[index - 1].strftime('%Y-%m-%d')})"
61
+
62
+ error_description = "There are #{days_between} days between #{previous_term_date_description} and :term_dates[#{index}]"
63
+
64
+ raise ArgumentError, "term dates can't be more than 1 year apart. #{error_description}"
65
+ end
66
+ end
67
+ end
68
+
69
+ def self.bullet?(loan_class)
70
+ loan_class == "LoanCreator::Bullet"
71
+ end
72
+ end
73
+ end
@@ -1,22 +1,13 @@
1
1
  # coding: utf-8
2
2
  module LoanCreator
3
3
  class Timetable
4
- # Used to calculate next term's date (see ActiveSupport#advance)
5
- PERIODS = {
6
- month: {months: 1},
7
- quarter: {months: 3},
8
- semester: {months: 6},
9
- year: {years: 1}
10
- }
4
+ attr_reader :loan, :terms, :starting_index #, :interests_start_date
11
5
 
12
- attr_reader :terms, :starts_on, :period, :starting_index #, :interests_start_date
13
-
14
- def initialize(starts_on:, period:, interests_start_date: nil, starting_index: 1)
15
- raise ArgumentError.new(:period) unless PERIODS.keys.include?(period)
6
+ delegate :starts_on, :period, to: :loan
16
7
 
8
+ def initialize(loan:, interests_start_date: nil, starting_index: 1)
17
9
  @terms = []
18
- @starts_on = (starts_on.is_a?(Date) ? starts_on : Date.parse(starts_on))
19
- @period = period
10
+ @loan = loan
20
11
  @starting_index = starting_index
21
12
 
22
13
  if interests_start_date
@@ -26,8 +17,11 @@ module LoanCreator
26
17
 
27
18
  def <<(term)
28
19
  raise ArgumentError.new('LoanCreator::Term expected') unless term.is_a?(LoanCreator::Term)
29
- term.index ||= autoincrement_index
30
- term.due_on ||= date_for(term.index)
20
+
21
+ @current_index = term.index || next_index
22
+
23
+ term.index = @current_index
24
+ term.due_on ||= loan.timetable_term_dates[term.index]
31
25
  @terms << term
32
26
  self
33
27
  end
@@ -43,29 +37,8 @@ module LoanCreator
43
37
  @terms.find { |term| term.index == index }
44
38
  end
45
39
 
46
- private
47
-
48
- def autoincrement_index
49
- @current_index = (@current_index.nil? ? @starting_index : @current_index + 1)
50
- end
51
-
52
- def date_for(index)
53
- @_dates ||= Hash.new do |dates, index|
54
- dates[index] =
55
- if index < 1
56
- dates[index + 1].advance(PERIODS.fetch(period).transform_values {|n| -n})
57
- elsif index == 1
58
- starts_on
59
- else
60
- dates[index - 1].advance(PERIODS.fetch(period))
61
- end
62
- end
63
-
64
- @_dates[index]
65
- end
66
-
67
- def reset_dates
68
- @_dates = nil
40
+ def next_index
41
+ @current_index.nil? ? @starting_index : @current_index + 1
69
42
  end
70
43
  end
71
44
  end
@@ -7,28 +7,39 @@ module LoanCreator
7
7
  reset_current_term
8
8
  @crd_beginning_of_period = amount
9
9
  @crd_end_of_period = amount
10
- (duration_in_periods - 1).times { timetable << current_term }
11
- compute_last_term
12
- timetable << current_term
10
+
11
+ duration_in_periods.times { |idx| timetable << compute_current_term(idx, timetable) }
12
+
13
13
  timetable
14
14
  end
15
15
 
16
16
  private
17
17
 
18
- def compute_last_term
19
- @crd_end_of_period = bigd('0')
20
- @period_interests = total_interests
21
- @period_capital = @crd_beginning_of_period
22
- @total_paid_capital_end_of_period = @period_capital
23
- @total_paid_interests_end_of_period = @period_interests
24
- @period_amount_to_pay = @period_capital + @period_interests
18
+ def compute_current_term(idx, timetable)
19
+ @due_on = timetable_term_dates[timetable.next_index]
20
+ last_period?(idx) ? compute_last_term(timetable) : compute_term(timetable)
21
+ current_term
22
+ end
23
+
24
+ def compute_last_term(timetable)
25
+ @crd_end_of_period = bigd('0')
26
+ @due_interests_beginning_of_period = @due_interests_end_of_period
27
+ @period_interests = @due_interests_end_of_period + compute_interests(@due_on, timetable)
28
+ @due_interests_end_of_period = 0
29
+ @period_capital = @crd_beginning_of_period
30
+ @total_paid_capital_end_of_period += @period_capital
31
+ @total_paid_interests_end_of_period += @period_interests
32
+ @period_amount_to_pay = @period_capital + @period_interests
33
+ end
34
+
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])
37
+ amount.mult(bigd(computed_periodic_interests_rate), BIG_DECIMAL_DIGITS)
25
38
  end
26
39
 
27
- def total_interests
28
- amount.mult(
29
- bigd(periodic_interests_rate) * bigd(duration_in_periods),
30
- BIG_DECIMAL_DIGITS
31
- )
40
+ def compute_term(timetable)
41
+ @due_interests_beginning_of_period = @due_interests_end_of_period
42
+ @due_interests_end_of_period += compute_interests(@due_on, timetable)
32
43
  end
33
44
  end
34
45
  end
@@ -1,3 +1,3 @@
1
1
  module LoanCreator
2
- VERSION = '0.7.1'.freeze
2
+ VERSION = '0.9.1'.freeze
3
3
  end
data/loan_creator.gemspec CHANGED
@@ -24,6 +24,8 @@ Gem::Specification.new do |spec|
24
24
  spec.add_development_dependency 'rspec', '~> 3.0'
25
25
  spec.add_development_dependency 'simplecov', '~> 0.16'
26
26
  spec.add_development_dependency 'byebug', '~> 11.0'
27
+ spec.add_development_dependency 'pry', '~> 0.13.1'
28
+ spec.add_development_dependency 'table_print', '~> 1.5'
27
29
 
28
30
  spec.add_runtime_dependency 'bigdecimal'
29
31
  spec.add_runtime_dependency 'activesupport'
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ require 'csv'
3
+ require 'open-uri'
4
+ require 'pry'
5
+ require 'securerandom'
6
+
7
+ csv_url = ARGV[0]
8
+
9
+ spec_order = %i[
10
+ index
11
+ due_on
12
+ crd_beginning_of_period
13
+ crd_end_of_period
14
+ period_theoric_interests
15
+ delta_interests
16
+ accrued_delta_interests
17
+ amount_to_add
18
+ period_interests
19
+ period_capital
20
+ total_paid_capital_end_of_period
21
+ total_paid_interests_end_of_period
22
+ period_amount_to_pay
23
+ due_interests_beginning_of_period
24
+ due_interests_end_of_period
25
+ ]
26
+
27
+ column_getter = {
28
+ index: proc { |r| r['t'].to_i },
29
+ due_on: proc { |r| r['date'] },
30
+ crd_beginning_of_period: proc { |r| r['crd_start'].gsub(',', '.').to_f },
31
+ crd_end_of_period: proc { |r| r['crd_end'].gsub(',', '.').to_f },
32
+ period_theoric_interests: proc { |r| r['period_theoric_interests'].gsub(',', '.').to_f },
33
+ delta_interests: proc { |r| r['delta_interests'].gsub(',', '.').to_f },
34
+ accrued_delta_interests: proc { |r| r['cumulated_delta_interests'].gsub(',', '.').to_f },
35
+ amount_to_add: proc { |r| r['amount_to_add'].gsub(',', '.').to_f },
36
+ period_interests: proc { |r| r['period_interests'].gsub(',', '.').to_f },
37
+ period_capital: proc { |r| r['period_capital'].gsub(',', '.').to_f },
38
+ total_paid_capital_end_of_period: proc { |r| r['total_capital_paid'].gsub(',', '.').to_f },
39
+ total_paid_interests_end_of_period: proc { |r| r['total_interests_paid'].gsub(',', '.').to_f },
40
+ period_amount_to_pay: proc { |r| r['period_total'].gsub(',', '.').to_f },
41
+ due_interests_beginning_of_period: proc { |r| r['due_interests_beginning_of_period'].gsub(',', '.').to_f },
42
+ due_interests_end_of_period: proc { |r| r['due_interests_end_of_period'].gsub(',', '.').to_f }
43
+ }
44
+
45
+ data = CSV.parse(URI.open(csv_url), headers: true).map(&:to_hash)
46
+
47
+ CSV.open("./spec/fixtures/new/#{SecureRandom.alphanumeric}.csv", 'w') do |csv|
48
+ data.each do |row|
49
+ csv << spec_order.map { |column| column_getter[column].call(row) }
50
+ end
51
+ end
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.7.1
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - thibaulth
@@ -9,10 +9,10 @@ authors:
9
9
  - younes.serraj
10
10
  - Antoine Becquet
11
11
  - Jerome Drevet
12
- autorequire:
12
+ autorequire:
13
13
  bindir: exe
14
14
  cert_chain: []
15
- date: 2021-01-11 00:00:00.000000000 Z
15
+ date: 2021-05-11 00:00:00.000000000 Z
16
16
  dependencies:
17
17
  - !ruby/object:Gem::Dependency
18
18
  name: bundler
@@ -84,6 +84,34 @@ dependencies:
84
84
  - - "~>"
85
85
  - !ruby/object:Gem::Version
86
86
  version: '11.0'
87
+ - !ruby/object:Gem::Dependency
88
+ name: pry
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: 0.13.1
94
+ type: :development
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: 0.13.1
101
+ - !ruby/object:Gem::Dependency
102
+ name: table_print
103
+ requirement: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '1.5'
108
+ type: :development
109
+ prerelease: false
110
+ version_requirements: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '1.5'
87
115
  - !ruby/object:Gem::Dependency
88
116
  name: bigdecimal
89
117
  requirement: !ruby/object:Gem::Requirement
@@ -112,7 +140,7 @@ dependencies:
112
140
  - - ">="
113
141
  - !ruby/object:Gem::Version
114
142
  version: '0'
115
- description:
143
+ description:
116
144
  email:
117
145
  - thibault@capsens.eu
118
146
  - nicolas.besnard@capsens.eu
@@ -149,15 +177,17 @@ files:
149
177
  - lib/loan_creator/linear.rb
150
178
  - lib/loan_creator/standard.rb
151
179
  - lib/loan_creator/term.rb
180
+ - lib/loan_creator/term_dates_validator.rb
152
181
  - lib/loan_creator/timetable.rb
153
182
  - lib/loan_creator/uncapitalized_bullet.rb
154
183
  - lib/loan_creator/version.rb
155
184
  - loan_creator.gemspec
185
+ - scripts/convert_export_to_spec.rb
156
186
  homepage: https://github.com/CapSens/loan-creator
157
187
  licenses:
158
188
  - MIT
159
189
  metadata: {}
160
- post_install_message:
190
+ post_install_message:
161
191
  rdoc_options: []
162
192
  require_paths:
163
193
  - lib
@@ -173,7 +203,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
173
203
  version: '0'
174
204
  requirements: []
175
205
  rubygems_version: 3.1.2
176
- signing_key:
206
+ signing_key:
177
207
  specification_version: 4
178
208
  summary: Create and update timetables from input data
179
209
  test_files: []