amortizy 1.0.0 → 2.1.0
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 +4 -4
- data/CHANGELOG.md +168 -3
- data/CONTRIBUTING.md +290 -0
- data/LICENSE.txt +1 -1
- data/README.md +490 -14
- data/Rakefile +3 -3
- data/lib/amortizy/console_formatter.rb +70 -0
- data/lib/amortizy/csv_formatter.rb +43 -0
- data/lib/amortizy/disclosure.rb +306 -0
- data/lib/amortizy/engine.rb +201 -278
- data/lib/amortizy/version.rb +1 -1
- data/lib/amortizy.rb +5 -2
- data/tasks/todo.md +203 -0
- metadata +12 -9
- data/.DS_Store +0 -0
- data/CODE_OF_CONDUCT.md +0 -132
- data/lib/.DS_Store +0 -0
data/lib/amortizy/engine.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# Amortization Schedule Generator
|
|
2
|
-
#
|
|
4
|
+
#
|
|
3
5
|
# Generates comprehensive loan amortization schedules with support for:
|
|
4
6
|
# - Grace periods with capitalized interest
|
|
5
7
|
# - Interest-only payment periods
|
|
@@ -12,14 +14,17 @@ require 'date'
|
|
|
12
14
|
require 'csv'
|
|
13
15
|
require 'holidays'
|
|
14
16
|
|
|
15
|
-
module Amortizy
|
|
17
|
+
module Amortizy
|
|
16
18
|
class AmortizationSchedule
|
|
17
|
-
attr_reader :start_date, :principal, :term_months, :annual_rate, :frequency
|
|
19
|
+
attr_reader :start_date, :principal, :term_months, :annual_rate, :frequency, :additional_fee_label,
|
|
20
|
+
:origination_fee, :additional_fee, :additional_fee_treatment
|
|
18
21
|
|
|
19
|
-
def initialize(start_date:, principal:,
|
|
22
|
+
def initialize(start_date:, principal:, annual_rate:, frequency:, term_months: nil, num_payments: nil,
|
|
23
|
+
origination_fee: 0, additional_fee: 0, additional_fee_label: 'Additional Fee',
|
|
24
|
+
additional_fee_treatment: :distributed, bank_days_only: false, interest_only_periods: 0,
|
|
25
|
+
grace_period_days: 0, interest_method: :simple)
|
|
20
26
|
@start_date = Date.parse(start_date.to_s)
|
|
21
27
|
@principal = principal.to_f
|
|
22
|
-
@term_months = term_months.to_i
|
|
23
28
|
@annual_rate = annual_rate.to_f / 100.0
|
|
24
29
|
@frequency = frequency.to_sym
|
|
25
30
|
@origination_fee = origination_fee.to_f
|
|
@@ -30,9 +35,10 @@ module Amortizy
|
|
|
30
35
|
@interest_only_periods = interest_only_periods.to_i
|
|
31
36
|
@grace_period_days = grace_period_days.to_i
|
|
32
37
|
@interest_method = interest_method.to_sym
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
|
|
39
|
+
validate_principal!
|
|
35
40
|
validate_frequency!
|
|
41
|
+
validate_term_input!(term_months, num_payments)
|
|
36
42
|
validate_fee_treatment!
|
|
37
43
|
validate_interest_only_periods!
|
|
38
44
|
validate_interest_method!
|
|
@@ -41,86 +47,162 @@ module Amortizy
|
|
|
41
47
|
def generate(output: :console, csv_path: nil)
|
|
42
48
|
case output
|
|
43
49
|
when :console
|
|
44
|
-
|
|
50
|
+
ConsoleFormatter.new(self).render
|
|
45
51
|
when :csv
|
|
46
|
-
raise ArgumentError,
|
|
47
|
-
|
|
52
|
+
raise ArgumentError, 'csv_path required for CSV output' unless csv_path
|
|
53
|
+
|
|
54
|
+
CsvFormatter.new(self).render(csv_path)
|
|
48
55
|
else
|
|
49
|
-
raise ArgumentError,
|
|
56
|
+
raise ArgumentError, 'Output must be :console or :csv'
|
|
50
57
|
end
|
|
51
58
|
end
|
|
52
59
|
|
|
60
|
+
def schedule
|
|
61
|
+
@schedule ||= generate_schedule_data.each(&:freeze).freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def summary
|
|
65
|
+
schedule
|
|
66
|
+
{
|
|
67
|
+
start_date: @start_date,
|
|
68
|
+
end_date: end_date,
|
|
69
|
+
principal: @principal,
|
|
70
|
+
total_payments: total_payments,
|
|
71
|
+
frequency: @frequency,
|
|
72
|
+
annual_rate: @annual_rate * 100.0,
|
|
73
|
+
payment_amount: payment_amount,
|
|
74
|
+
total_interest: total_interest,
|
|
75
|
+
total_paid: total_paid
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def end_date
|
|
80
|
+
schedule.last[:date]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def total_interest
|
|
84
|
+
schedule.sum { |row| row[:interest_payment] || 0 }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def total_paid
|
|
88
|
+
schedule.sum { |row| row[:total_payment] || 0 }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def payment_amount
|
|
92
|
+
calculate_payment
|
|
93
|
+
end
|
|
94
|
+
|
|
53
95
|
private
|
|
54
96
|
|
|
97
|
+
def validate_principal!
|
|
98
|
+
raise ArgumentError, 'principal must be positive' unless @principal.positive?
|
|
99
|
+
end
|
|
100
|
+
|
|
55
101
|
def validate_frequency!
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
102
|
+
return if %i[daily weekly monthly biweekly].include?(@frequency)
|
|
103
|
+
|
|
104
|
+
raise ArgumentError, 'Frequency must be :daily, :weekly, :biweekly, or :monthly'
|
|
59
105
|
end
|
|
60
106
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
107
|
+
def validate_term_input!(term_months, num_payments)
|
|
108
|
+
raise ArgumentError, 'Cannot specify both term_months and num_payments' if term_months && num_payments
|
|
109
|
+
|
|
110
|
+
raise ArgumentError, 'Must specify either term_months or num_payments' if term_months.nil? && num_payments.nil?
|
|
111
|
+
|
|
112
|
+
if term_months
|
|
113
|
+
@term_months = term_months.to_i
|
|
114
|
+
raise ArgumentError, 'term_months must be a positive integer' unless @term_months.positive?
|
|
115
|
+
|
|
116
|
+
@num_payments = calculate_payments_from_term
|
|
117
|
+
else
|
|
118
|
+
@num_payments = num_payments.to_i
|
|
119
|
+
raise ArgumentError, 'num_payments must be a positive integer' unless @num_payments.positive?
|
|
120
|
+
|
|
121
|
+
@term_months = nil
|
|
64
122
|
end
|
|
65
123
|
end
|
|
66
124
|
|
|
67
125
|
def validate_fee_treatment!
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
126
|
+
return if %i[distributed add_to_principal separate_payment].include?(@additional_fee_treatment)
|
|
127
|
+
|
|
128
|
+
raise ArgumentError, 'Additional fee treatment must be :distributed, :add_to_principal, or :separate_payment'
|
|
71
129
|
end
|
|
72
130
|
|
|
73
131
|
def validate_interest_only_periods!
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
132
|
+
num = total_payments
|
|
133
|
+
return unless @interest_only_periods >= num
|
|
134
|
+
|
|
135
|
+
raise ArgumentError,
|
|
136
|
+
"Interest-only periods (#{@interest_only_periods}) must be less than total payments (#{num})"
|
|
78
137
|
end
|
|
79
138
|
|
|
80
139
|
def validate_interest_method!
|
|
81
|
-
|
|
82
|
-
|
|
140
|
+
return if %i[simple precomputed].include?(@interest_method)
|
|
141
|
+
|
|
142
|
+
raise ArgumentError, 'Interest method must be :simple or :precomputed'
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def total_payments
|
|
146
|
+
@num_payments
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def calculate_payments_from_term
|
|
150
|
+
end_date = @start_date >> @term_months
|
|
151
|
+
|
|
152
|
+
case @frequency
|
|
153
|
+
when :monthly
|
|
154
|
+
@term_months
|
|
155
|
+
when :biweekly
|
|
156
|
+
((end_date - @start_date) / 14.0).round
|
|
157
|
+
when :weekly
|
|
158
|
+
((end_date - @start_date) / 7.0).round
|
|
159
|
+
when :daily
|
|
160
|
+
if @bank_days_only
|
|
161
|
+
count_business_days(@start_date, end_date)
|
|
162
|
+
else
|
|
163
|
+
(end_date - @start_date).to_i
|
|
164
|
+
end
|
|
83
165
|
end
|
|
84
166
|
end
|
|
85
167
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
payment_schedule[@term_months][@frequency]
|
|
168
|
+
def count_business_days(from_date, to_date)
|
|
169
|
+
count = 0
|
|
170
|
+
current = from_date
|
|
171
|
+
while current < to_date
|
|
172
|
+
count += 1 if bank_day?(current)
|
|
173
|
+
current += 1
|
|
174
|
+
end
|
|
175
|
+
count
|
|
96
176
|
end
|
|
97
177
|
|
|
98
178
|
def calculate_average_days_per_period
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
179
|
+
base_days = case @frequency
|
|
180
|
+
when :daily then 1
|
|
181
|
+
when :weekly then 7
|
|
182
|
+
when :biweekly then 14
|
|
183
|
+
when :monthly then 30.4375 # 365.25 / 12
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
return base_days unless @bank_days_only
|
|
187
|
+
|
|
188
|
+
num = total_payments
|
|
189
|
+
current_date = first_payment_date
|
|
190
|
+
total_days = 0
|
|
191
|
+
sample_size = [30, num].min
|
|
192
|
+
|
|
193
|
+
(1..sample_size).each do |_i|
|
|
194
|
+
next_date = next_payment_date(current_date)
|
|
195
|
+
total_days += calculate_days_between(current_date, next_date)
|
|
196
|
+
current_date = next_date
|
|
115
197
|
end
|
|
116
|
-
|
|
117
|
-
|
|
198
|
+
|
|
199
|
+
total_days.to_f / sample_size
|
|
118
200
|
end
|
|
119
201
|
|
|
120
202
|
def estimate_total_loan_days
|
|
121
|
-
|
|
203
|
+
num = total_payments
|
|
122
204
|
avg_days_per_period = calculate_average_days_per_period
|
|
123
|
-
|
|
205
|
+
num * avg_days_per_period
|
|
124
206
|
end
|
|
125
207
|
|
|
126
208
|
def calculate_precomputed_total_interest
|
|
@@ -130,7 +212,6 @@ module Amortizy
|
|
|
130
212
|
end
|
|
131
213
|
|
|
132
214
|
def precomputed_interest_per_payment
|
|
133
|
-
total_payments = calculate_total_payments
|
|
134
215
|
calculate_precomputed_total_interest / total_payments
|
|
135
216
|
end
|
|
136
217
|
|
|
@@ -139,14 +220,15 @@ module Amortizy
|
|
|
139
220
|
end
|
|
140
221
|
|
|
141
222
|
def grace_period_interest
|
|
142
|
-
return 0.0 if @grace_period_days
|
|
223
|
+
return 0.0 if @grace_period_days.zero?
|
|
224
|
+
|
|
143
225
|
grace_rate = (@annual_rate / 365.0) * @grace_period_days
|
|
144
226
|
initial_principal_with_origination * grace_rate
|
|
145
227
|
end
|
|
146
228
|
|
|
147
229
|
def effective_principal
|
|
148
230
|
base_principal = initial_principal_with_origination + grace_period_interest
|
|
149
|
-
|
|
231
|
+
|
|
150
232
|
case @additional_fee_treatment
|
|
151
233
|
when :add_to_principal
|
|
152
234
|
base_principal + @additional_fee
|
|
@@ -156,7 +238,7 @@ module Amortizy
|
|
|
156
238
|
end
|
|
157
239
|
|
|
158
240
|
def first_payment_date
|
|
159
|
-
if @grace_period_days
|
|
241
|
+
if @grace_period_days.positive?
|
|
160
242
|
grace_end_date = @start_date + @grace_period_days
|
|
161
243
|
next_bank_day(grace_end_date)
|
|
162
244
|
else
|
|
@@ -165,31 +247,31 @@ module Amortizy
|
|
|
165
247
|
end
|
|
166
248
|
|
|
167
249
|
def calculate_payment
|
|
168
|
-
|
|
169
|
-
principal_payments =
|
|
170
|
-
|
|
250
|
+
num = total_payments
|
|
251
|
+
principal_payments = num - @interest_only_periods
|
|
252
|
+
|
|
171
253
|
if @interest_method == :precomputed
|
|
172
254
|
principal_payment_portion = effective_principal / principal_payments
|
|
173
255
|
interest_portion = precomputed_interest_per_payment
|
|
174
|
-
|
|
256
|
+
|
|
175
257
|
if @additional_fee_treatment == :distributed
|
|
176
|
-
principal_payment_portion + interest_portion + (@additional_fee /
|
|
258
|
+
principal_payment_portion + interest_portion + (@additional_fee / num)
|
|
177
259
|
else
|
|
178
260
|
principal_payment_portion + interest_portion
|
|
179
261
|
end
|
|
180
262
|
else
|
|
181
263
|
days_per_period = calculate_average_days_per_period
|
|
182
264
|
period_rate = (@annual_rate / 365.0) * days_per_period
|
|
183
|
-
|
|
184
|
-
base_payment = if period_rate
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
265
|
+
|
|
266
|
+
base_payment = if period_rate.zero?
|
|
267
|
+
effective_principal / principal_payments
|
|
268
|
+
else
|
|
269
|
+
effective_principal * (period_rate * ((1 + period_rate)**principal_payments)) /
|
|
270
|
+
(((1 + period_rate)**principal_payments) - 1)
|
|
271
|
+
end
|
|
190
272
|
|
|
191
273
|
if @additional_fee_treatment == :distributed
|
|
192
|
-
base_payment + (@additional_fee /
|
|
274
|
+
base_payment + (@additional_fee / num)
|
|
193
275
|
else
|
|
194
276
|
base_payment
|
|
195
277
|
end
|
|
@@ -205,30 +287,41 @@ module Amortizy
|
|
|
205
287
|
return true unless @bank_days_only
|
|
206
288
|
return false if date.saturday? || date.sunday?
|
|
207
289
|
return false if federal_holiday?(date)
|
|
290
|
+
|
|
208
291
|
true
|
|
209
292
|
end
|
|
210
293
|
|
|
211
294
|
def next_bank_day(date)
|
|
212
295
|
return date unless @bank_days_only
|
|
213
|
-
|
|
296
|
+
|
|
214
297
|
current = date
|
|
215
|
-
until bank_day?(current)
|
|
216
|
-
current += 1
|
|
217
|
-
end
|
|
298
|
+
current += 1 until bank_day?(current)
|
|
218
299
|
current
|
|
219
300
|
end
|
|
220
301
|
|
|
221
302
|
def next_payment_date(current_date)
|
|
222
|
-
case @frequency
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
303
|
+
next_date = case @frequency
|
|
304
|
+
when :daily
|
|
305
|
+
current_date + 1
|
|
306
|
+
when :weekly
|
|
307
|
+
current_date + 7
|
|
308
|
+
when :biweekly
|
|
309
|
+
current_date + 14
|
|
310
|
+
when :monthly
|
|
311
|
+
advance_by_month(current_date)
|
|
312
|
+
end
|
|
313
|
+
|
|
229
314
|
next_bank_day(next_date)
|
|
230
315
|
end
|
|
231
316
|
|
|
317
|
+
def advance_by_month(date)
|
|
318
|
+
target_year = date.month == 12 ? date.year + 1 : date.year
|
|
319
|
+
target_month = date.month == 12 ? 1 : date.month + 1
|
|
320
|
+
last_day = Date.new(target_year, target_month, -1).day
|
|
321
|
+
target_day = [date.day, last_day].min
|
|
322
|
+
Date.new(target_year, target_month, target_day)
|
|
323
|
+
end
|
|
324
|
+
|
|
232
325
|
def calculate_days_between(start_date, end_date)
|
|
233
326
|
(end_date - start_date).to_i
|
|
234
327
|
end
|
|
@@ -240,10 +333,10 @@ module Amortizy
|
|
|
240
333
|
payment_date = first_payment_date
|
|
241
334
|
previous_payment_date = first_payment_date
|
|
242
335
|
payment_number = 0
|
|
243
|
-
|
|
336
|
+
num = total_payments
|
|
244
337
|
schedule_data = []
|
|
245
338
|
|
|
246
|
-
if @grace_period_days
|
|
339
|
+
if @grace_period_days.positive?
|
|
247
340
|
grace_interest = grace_period_interest
|
|
248
341
|
schedule_data << {
|
|
249
342
|
payment_number: 'Grace',
|
|
@@ -261,7 +354,7 @@ module Amortizy
|
|
|
261
354
|
}
|
|
262
355
|
end
|
|
263
356
|
|
|
264
|
-
if @additional_fee_treatment == :separate_payment && @additional_fee
|
|
357
|
+
if @additional_fee_treatment == :separate_payment && @additional_fee.positive?
|
|
265
358
|
payment_date = next_payment_date(payment_date)
|
|
266
359
|
schedule_data << {
|
|
267
360
|
payment_number: 0,
|
|
@@ -279,38 +372,49 @@ module Amortizy
|
|
|
279
372
|
previous_payment_date = payment_date
|
|
280
373
|
end
|
|
281
374
|
|
|
282
|
-
additional_fee_per_payment = @additional_fee_treatment == :distributed ? (@additional_fee /
|
|
375
|
+
additional_fee_per_payment = @additional_fee_treatment == :distributed ? (@additional_fee / num) : 0.0
|
|
283
376
|
precomputed_interest = @interest_method == :precomputed ? precomputed_interest_per_payment : 0.0
|
|
284
377
|
|
|
285
|
-
|
|
378
|
+
# For monthly frequency, track the target date separately from the actual
|
|
379
|
+
# payment date to prevent bank-day adjustments from compounding drift.
|
|
380
|
+
monthly_target = @frequency == :monthly ? first_payment_date : nil
|
|
381
|
+
|
|
382
|
+
while payment_number < num && balance > 0.01
|
|
286
383
|
payment_number += 1
|
|
287
|
-
|
|
384
|
+
|
|
385
|
+
if @frequency == :monthly && monthly_target
|
|
386
|
+
monthly_target = advance_by_month(monthly_target)
|
|
387
|
+
payment_date = next_bank_day(monthly_target)
|
|
388
|
+
else
|
|
389
|
+
payment_date = next_payment_date(previous_payment_date)
|
|
390
|
+
end
|
|
391
|
+
|
|
288
392
|
days_in_period = calculate_days_between(previous_payment_date, payment_date)
|
|
289
|
-
|
|
393
|
+
|
|
290
394
|
if @interest_method == :precomputed
|
|
291
395
|
interest_payment = precomputed_interest
|
|
292
396
|
else
|
|
293
397
|
period_rate = (@annual_rate / 365.0) * days_in_period
|
|
294
398
|
interest_payment = balance * period_rate
|
|
295
399
|
end
|
|
296
|
-
|
|
400
|
+
|
|
297
401
|
accrued_interest += interest_payment
|
|
298
402
|
is_interest_only = payment_number <= @interest_only_periods
|
|
299
|
-
|
|
403
|
+
|
|
300
404
|
if is_interest_only
|
|
301
405
|
principal_payment = 0.0
|
|
302
406
|
payment_type = 'Interest Only'
|
|
303
407
|
else
|
|
304
408
|
principal_payment = [payment_amount - interest_payment - additional_fee_per_payment, balance].min
|
|
305
|
-
principal_payment = balance if payment_number ==
|
|
409
|
+
principal_payment = balance if payment_number == num
|
|
306
410
|
payment_type = 'Regular Payment'
|
|
307
411
|
end
|
|
308
|
-
|
|
412
|
+
|
|
309
413
|
total_payment = principal_payment + interest_payment + additional_fee_per_payment
|
|
310
414
|
balance -= principal_payment
|
|
311
415
|
balance = 0 if balance < 0.01
|
|
312
416
|
total_balance = balance + accrued_interest
|
|
313
|
-
|
|
417
|
+
|
|
314
418
|
schedule_data << {
|
|
315
419
|
payment_number: payment_number,
|
|
316
420
|
date: payment_date,
|
|
@@ -324,193 +428,12 @@ module Amortizy
|
|
|
324
428
|
payment_type: payment_type,
|
|
325
429
|
days_in_period: days_in_period
|
|
326
430
|
}
|
|
327
|
-
|
|
431
|
+
|
|
328
432
|
accrued_interest = 0.0
|
|
329
433
|
previous_payment_date = payment_date
|
|
330
434
|
end
|
|
331
435
|
|
|
332
436
|
schedule_data
|
|
333
437
|
end
|
|
334
|
-
|
|
335
|
-
def generate_console_output
|
|
336
|
-
puts format_header
|
|
337
|
-
puts "-" * 195
|
|
338
|
-
|
|
339
|
-
generate_schedule_data.each do |row|
|
|
340
|
-
if row[:payment_type] == 'Grace Period'
|
|
341
|
-
puts format_grace_row(
|
|
342
|
-
row[:payment_number],
|
|
343
|
-
row[:date],
|
|
344
|
-
row[:grace_interest_capitalized],
|
|
345
|
-
row[:principal_balance],
|
|
346
|
-
row[:days_in_period]
|
|
347
|
-
)
|
|
348
|
-
else
|
|
349
|
-
puts format_row(
|
|
350
|
-
row[:payment_number],
|
|
351
|
-
row[:date],
|
|
352
|
-
row[:principal_payment],
|
|
353
|
-
row[:interest_payment],
|
|
354
|
-
row[:additional_fee_payment],
|
|
355
|
-
row[:total_payment],
|
|
356
|
-
row[:principal_balance],
|
|
357
|
-
row[:accrued_interest],
|
|
358
|
-
row[:total_balance],
|
|
359
|
-
row[:payment_type],
|
|
360
|
-
row[:days_in_period]
|
|
361
|
-
)
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
|
|
365
|
-
print_summary
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def generate_csv_output(csv_path)
|
|
369
|
-
CSV.open(csv_path, 'w') do |csv|
|
|
370
|
-
csv << [
|
|
371
|
-
'Payment Number',
|
|
372
|
-
'Date',
|
|
373
|
-
'Days in Period',
|
|
374
|
-
'Principal Payment',
|
|
375
|
-
'Interest Payment',
|
|
376
|
-
'Additional Fee Payment',
|
|
377
|
-
'Total Payment',
|
|
378
|
-
'Principal Balance Remaining',
|
|
379
|
-
'Accrued Interest',
|
|
380
|
-
'Total Balance',
|
|
381
|
-
'Payment Type',
|
|
382
|
-
'Grace Interest Capitalized'
|
|
383
|
-
]
|
|
384
|
-
|
|
385
|
-
generate_schedule_data.each do |row|
|
|
386
|
-
csv << [
|
|
387
|
-
row[:payment_number],
|
|
388
|
-
row[:date].strftime("%Y-%m-%d"),
|
|
389
|
-
row[:days_in_period],
|
|
390
|
-
format("%.2f", row[:principal_payment] || 0),
|
|
391
|
-
format("%.2f", row[:interest_payment] || 0),
|
|
392
|
-
format("%.2f", row[:additional_fee_payment] || 0),
|
|
393
|
-
format("%.2f", row[:total_payment] || 0),
|
|
394
|
-
format("%.2f", row[:principal_balance]),
|
|
395
|
-
format("%.2f", row[:accrued_interest] || 0),
|
|
396
|
-
format("%.2f", row[:total_balance]),
|
|
397
|
-
row[:payment_type],
|
|
398
|
-
row[:grace_interest_capitalized] ? format("%.2f", row[:grace_interest_capitalized]) : ''
|
|
399
|
-
]
|
|
400
|
-
end
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
puts "CSV file generated: #{csv_path}"
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
def print_summary
|
|
407
|
-
puts "\n" + "=" * 195
|
|
408
|
-
puts "LOAN SUMMARY"
|
|
409
|
-
puts "=" * 195
|
|
410
|
-
puts "Loan Start Date: #{@start_date.strftime('%Y-%m-%d')}"
|
|
411
|
-
puts "First Payment Date: #{first_payment_date.strftime('%Y-%m-%d')}"
|
|
412
|
-
puts "Term: #{@term_months} months"
|
|
413
|
-
puts "Payment Frequency: #{@frequency.to_s.capitalize}"
|
|
414
|
-
puts "Grace Period: #{@grace_period_days} days"
|
|
415
|
-
|
|
416
|
-
if @grace_period_days > 0
|
|
417
|
-
puts "Grace Period Interest (Capitalized): $#{format('%.2f', grace_period_interest)}"
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
puts "\nOriginal Principal: $#{format('%.2f', @principal)}"
|
|
421
|
-
puts "Origination Fee: $#{format('%.2f', @origination_fee)} (added to principal)"
|
|
422
|
-
puts "Additional Fee: $#{format('%.2f', @additional_fee)}"
|
|
423
|
-
puts "#{@additional_fee_label} Treatment: #{@additional_fee_treatment.to_s.split('_').map(&:capitalize).join(' ')}"
|
|
424
|
-
puts "Bank Days Only: #{@bank_days_only}"
|
|
425
|
-
puts "Interest-Only Periods: #{@interest_only_periods}"
|
|
426
|
-
puts "Interest Method: #{@interest_method.to_s.capitalize}"
|
|
427
|
-
|
|
428
|
-
puts "\nPrincipal after Origination Fee: $#{format('%.2f', initial_principal_with_origination)}"
|
|
429
|
-
|
|
430
|
-
if @grace_period_days > 0
|
|
431
|
-
puts "Principal after Grace Period (with capitalized interest): $#{format('%.2f', initial_principal_with_origination + grace_period_interest)}"
|
|
432
|
-
end
|
|
433
|
-
|
|
434
|
-
case @additional_fee_treatment
|
|
435
|
-
when :add_to_principal
|
|
436
|
-
puts "Total Principal (with all fees): $#{format('%.2f', effective_principal)}"
|
|
437
|
-
when :distributed
|
|
438
|
-
puts "#{@additional_fee_label} per payment: $#{format('%.2f', @additional_fee / calculate_total_payments)}"
|
|
439
|
-
when :separate_payment
|
|
440
|
-
puts "#{@additional_fee_label} collected as separate payment"
|
|
441
|
-
end
|
|
442
|
-
|
|
443
|
-
if @interest_method == :precomputed
|
|
444
|
-
puts "\nPrecomputed Interest Calculation:"
|
|
445
|
-
puts " Estimated Total Loan Days: #{format('%.0f', estimate_total_loan_days)}"
|
|
446
|
-
puts " Total Precomputed Interest: $#{format('%.2f', calculate_precomputed_total_interest)}"
|
|
447
|
-
puts " Interest per Payment: $#{format('%.2f', precomputed_interest_per_payment)}"
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
schedule_data = generate_schedule_data
|
|
451
|
-
total_interest = schedule_data.sum { |row| row[:interest_payment] || 0 }
|
|
452
|
-
total_additional_fees = schedule_data.sum { |row| row[:additional_fee_payment] || 0 }
|
|
453
|
-
total_paid = effective_principal + total_interest + (@additional_fee_treatment == :separate_payment ? @additional_fee : 0)
|
|
454
|
-
|
|
455
|
-
puts "\nTotal Interest Paid (during payments): $#{format('%.2f', total_interest)}"
|
|
456
|
-
if @grace_period_days > 0
|
|
457
|
-
puts "Total Interest Including Grace Period: $#{format('%.2f', total_interest + grace_period_interest)}"
|
|
458
|
-
end
|
|
459
|
-
puts "Total #{@additional_fee_label} Paid: $#{format('%.2f', total_additional_fees)}"
|
|
460
|
-
puts "Total Amount Paid: $#{format('%.2f', total_paid)}"
|
|
461
|
-
puts "=" * 195
|
|
462
|
-
end
|
|
463
|
-
|
|
464
|
-
def format_header
|
|
465
|
-
format(
|
|
466
|
-
"%-8s %-12s %-8s %15s %15s %18s %18s %20s %18s %18s %20s",
|
|
467
|
-
"Payment",
|
|
468
|
-
"Date",
|
|
469
|
-
"Days",
|
|
470
|
-
"Principal Pmt",
|
|
471
|
-
"Interest Pmt",
|
|
472
|
-
@additional_fee_label,
|
|
473
|
-
"Total Payment",
|
|
474
|
-
"Principal Balance",
|
|
475
|
-
"Accrued Interest",
|
|
476
|
-
"Total Balance",
|
|
477
|
-
"Payment Type"
|
|
478
|
-
)
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
def format_grace_row(payment_num, date, grace_interest, principal_balance, days)
|
|
482
|
-
format(
|
|
483
|
-
"%-8s %-12s %-8d %15s %15s %18s %18s %20.2f %18s %18.2f %20s",
|
|
484
|
-
"Grace",
|
|
485
|
-
date.strftime("%Y-%m-%d"),
|
|
486
|
-
days,
|
|
487
|
-
"---",
|
|
488
|
-
"+#{format('%.2f', grace_interest)}",
|
|
489
|
-
"---",
|
|
490
|
-
"0.00",
|
|
491
|
-
principal_balance,
|
|
492
|
-
"---",
|
|
493
|
-
principal_balance,
|
|
494
|
-
"Grace Period"
|
|
495
|
-
)
|
|
496
|
-
end
|
|
497
|
-
|
|
498
|
-
def format_row(payment_num, date, principal_pmt, interest_pmt, additional_fee_pmt, total_pmt, principal_balance, accrued_interest, total_balance, payment_type, days)
|
|
499
|
-
format(
|
|
500
|
-
"%-8s %-12s %-8d %15.2f %15.2f %18.2f %18.2f %20.2f %18.2f %18.2f %20s",
|
|
501
|
-
payment_num == 0 ? "Fee" : payment_num.to_s,
|
|
502
|
-
date.strftime("%Y-%m-%d"),
|
|
503
|
-
days,
|
|
504
|
-
principal_pmt,
|
|
505
|
-
interest_pmt,
|
|
506
|
-
additional_fee_pmt,
|
|
507
|
-
total_pmt,
|
|
508
|
-
principal_balance,
|
|
509
|
-
accrued_interest,
|
|
510
|
-
total_balance,
|
|
511
|
-
payment_type
|
|
512
|
-
)
|
|
513
|
-
end
|
|
514
438
|
end
|
|
515
439
|
end
|
|
516
|
-
|
data/lib/amortizy/version.rb
CHANGED
data/lib/amortizy.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative
|
|
4
|
-
require_relative
|
|
3
|
+
require_relative 'amortizy/version'
|
|
4
|
+
require_relative 'amortizy/engine'
|
|
5
|
+
require_relative 'amortizy/console_formatter'
|
|
6
|
+
require_relative 'amortizy/csv_formatter'
|
|
7
|
+
require_relative 'amortizy/disclosure'
|
|
5
8
|
|
|
6
9
|
module Amortizy
|
|
7
10
|
class Error < StandardError; end
|