amortizy 1.0.0 → 2.0.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 +142 -3
- data/CONTRIBUTING.md +290 -0
- data/LICENSE.txt +1 -1
- data/README.md +385 -14
- data/Rakefile +3 -3
- data/lib/amortizy/console_formatter.rb +70 -0
- data/lib/amortizy/csv_formatter.rb +43 -0
- data/lib/amortizy/engine.rb +200 -278
- data/lib/amortizy/version.rb +1 -1
- data/lib/amortizy.rb +4 -2
- data/tasks/todo.md +74 -0
- metadata +11 -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,16 @@ 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
|
|
18
20
|
|
|
19
|
-
def initialize(start_date:, principal:,
|
|
21
|
+
def initialize(start_date:, principal:, annual_rate:, frequency:, term_months: nil, num_payments: nil,
|
|
22
|
+
origination_fee: 0, additional_fee: 0, additional_fee_label: 'Additional Fee',
|
|
23
|
+
additional_fee_treatment: :distributed, bank_days_only: false, interest_only_periods: 0,
|
|
24
|
+
grace_period_days: 0, interest_method: :simple)
|
|
20
25
|
@start_date = Date.parse(start_date.to_s)
|
|
21
26
|
@principal = principal.to_f
|
|
22
|
-
@term_months = term_months.to_i
|
|
23
27
|
@annual_rate = annual_rate.to_f / 100.0
|
|
24
28
|
@frequency = frequency.to_sym
|
|
25
29
|
@origination_fee = origination_fee.to_f
|
|
@@ -30,9 +34,10 @@ module Amortizy
|
|
|
30
34
|
@interest_only_periods = interest_only_periods.to_i
|
|
31
35
|
@grace_period_days = grace_period_days.to_i
|
|
32
36
|
@interest_method = interest_method.to_sym
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
|
|
38
|
+
validate_principal!
|
|
35
39
|
validate_frequency!
|
|
40
|
+
validate_term_input!(term_months, num_payments)
|
|
36
41
|
validate_fee_treatment!
|
|
37
42
|
validate_interest_only_periods!
|
|
38
43
|
validate_interest_method!
|
|
@@ -41,86 +46,162 @@ module Amortizy
|
|
|
41
46
|
def generate(output: :console, csv_path: nil)
|
|
42
47
|
case output
|
|
43
48
|
when :console
|
|
44
|
-
|
|
49
|
+
ConsoleFormatter.new(self).render
|
|
45
50
|
when :csv
|
|
46
|
-
raise ArgumentError,
|
|
47
|
-
|
|
51
|
+
raise ArgumentError, 'csv_path required for CSV output' unless csv_path
|
|
52
|
+
|
|
53
|
+
CsvFormatter.new(self).render(csv_path)
|
|
48
54
|
else
|
|
49
|
-
raise ArgumentError,
|
|
55
|
+
raise ArgumentError, 'Output must be :console or :csv'
|
|
50
56
|
end
|
|
51
57
|
end
|
|
52
58
|
|
|
59
|
+
def schedule
|
|
60
|
+
@schedule ||= generate_schedule_data.each(&:freeze).freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def summary
|
|
64
|
+
schedule
|
|
65
|
+
{
|
|
66
|
+
start_date: @start_date,
|
|
67
|
+
end_date: end_date,
|
|
68
|
+
principal: @principal,
|
|
69
|
+
total_payments: total_payments,
|
|
70
|
+
frequency: @frequency,
|
|
71
|
+
annual_rate: @annual_rate * 100.0,
|
|
72
|
+
payment_amount: payment_amount,
|
|
73
|
+
total_interest: total_interest,
|
|
74
|
+
total_paid: total_paid
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def end_date
|
|
79
|
+
schedule.last[:date]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def total_interest
|
|
83
|
+
schedule.sum { |row| row[:interest_payment] || 0 }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def total_paid
|
|
87
|
+
schedule.sum { |row| row[:total_payment] || 0 }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def payment_amount
|
|
91
|
+
calculate_payment
|
|
92
|
+
end
|
|
93
|
+
|
|
53
94
|
private
|
|
54
95
|
|
|
96
|
+
def validate_principal!
|
|
97
|
+
raise ArgumentError, 'principal must be positive' unless @principal.positive?
|
|
98
|
+
end
|
|
99
|
+
|
|
55
100
|
def validate_frequency!
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
101
|
+
return if %i[daily weekly monthly biweekly].include?(@frequency)
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, 'Frequency must be :daily, :weekly, :biweekly, or :monthly'
|
|
59
104
|
end
|
|
60
105
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
106
|
+
def validate_term_input!(term_months, num_payments)
|
|
107
|
+
raise ArgumentError, 'Cannot specify both term_months and num_payments' if term_months && num_payments
|
|
108
|
+
|
|
109
|
+
raise ArgumentError, 'Must specify either term_months or num_payments' if term_months.nil? && num_payments.nil?
|
|
110
|
+
|
|
111
|
+
if term_months
|
|
112
|
+
@term_months = term_months.to_i
|
|
113
|
+
raise ArgumentError, 'term_months must be a positive integer' unless @term_months.positive?
|
|
114
|
+
|
|
115
|
+
@num_payments = calculate_payments_from_term
|
|
116
|
+
else
|
|
117
|
+
@num_payments = num_payments.to_i
|
|
118
|
+
raise ArgumentError, 'num_payments must be a positive integer' unless @num_payments.positive?
|
|
119
|
+
|
|
120
|
+
@term_months = nil
|
|
64
121
|
end
|
|
65
122
|
end
|
|
66
123
|
|
|
67
124
|
def validate_fee_treatment!
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
125
|
+
return if %i[distributed add_to_principal separate_payment].include?(@additional_fee_treatment)
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, 'Additional fee treatment must be :distributed, :add_to_principal, or :separate_payment'
|
|
71
128
|
end
|
|
72
129
|
|
|
73
130
|
def validate_interest_only_periods!
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
131
|
+
num = total_payments
|
|
132
|
+
return unless @interest_only_periods >= num
|
|
133
|
+
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"Interest-only periods (#{@interest_only_periods}) must be less than total payments (#{num})"
|
|
78
136
|
end
|
|
79
137
|
|
|
80
138
|
def validate_interest_method!
|
|
81
|
-
|
|
82
|
-
|
|
139
|
+
return if %i[simple precomputed].include?(@interest_method)
|
|
140
|
+
|
|
141
|
+
raise ArgumentError, 'Interest method must be :simple or :precomputed'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def total_payments
|
|
145
|
+
@num_payments
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def calculate_payments_from_term
|
|
149
|
+
end_date = @start_date >> @term_months
|
|
150
|
+
|
|
151
|
+
case @frequency
|
|
152
|
+
when :monthly
|
|
153
|
+
@term_months
|
|
154
|
+
when :biweekly
|
|
155
|
+
((end_date - @start_date) / 14.0).round
|
|
156
|
+
when :weekly
|
|
157
|
+
((end_date - @start_date) / 7.0).round
|
|
158
|
+
when :daily
|
|
159
|
+
if @bank_days_only
|
|
160
|
+
count_business_days(@start_date, end_date)
|
|
161
|
+
else
|
|
162
|
+
(end_date - @start_date).to_i
|
|
163
|
+
end
|
|
83
164
|
end
|
|
84
165
|
end
|
|
85
166
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
payment_schedule[@term_months][@frequency]
|
|
167
|
+
def count_business_days(from_date, to_date)
|
|
168
|
+
count = 0
|
|
169
|
+
current = from_date
|
|
170
|
+
while current < to_date
|
|
171
|
+
count += 1 if bank_day?(current)
|
|
172
|
+
current += 1
|
|
173
|
+
end
|
|
174
|
+
count
|
|
96
175
|
end
|
|
97
176
|
|
|
98
177
|
def calculate_average_days_per_period
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
178
|
+
base_days = case @frequency
|
|
179
|
+
when :daily then 1
|
|
180
|
+
when :weekly then 7
|
|
181
|
+
when :biweekly then 14
|
|
182
|
+
when :monthly then 30.4375 # 365.25 / 12
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
return base_days unless @bank_days_only
|
|
186
|
+
|
|
187
|
+
num = total_payments
|
|
188
|
+
current_date = first_payment_date
|
|
189
|
+
total_days = 0
|
|
190
|
+
sample_size = [30, num].min
|
|
191
|
+
|
|
192
|
+
(1..sample_size).each do |_i|
|
|
193
|
+
next_date = next_payment_date(current_date)
|
|
194
|
+
total_days += calculate_days_between(current_date, next_date)
|
|
195
|
+
current_date = next_date
|
|
115
196
|
end
|
|
116
|
-
|
|
117
|
-
|
|
197
|
+
|
|
198
|
+
total_days.to_f / sample_size
|
|
118
199
|
end
|
|
119
200
|
|
|
120
201
|
def estimate_total_loan_days
|
|
121
|
-
|
|
202
|
+
num = total_payments
|
|
122
203
|
avg_days_per_period = calculate_average_days_per_period
|
|
123
|
-
|
|
204
|
+
num * avg_days_per_period
|
|
124
205
|
end
|
|
125
206
|
|
|
126
207
|
def calculate_precomputed_total_interest
|
|
@@ -130,7 +211,6 @@ module Amortizy
|
|
|
130
211
|
end
|
|
131
212
|
|
|
132
213
|
def precomputed_interest_per_payment
|
|
133
|
-
total_payments = calculate_total_payments
|
|
134
214
|
calculate_precomputed_total_interest / total_payments
|
|
135
215
|
end
|
|
136
216
|
|
|
@@ -139,14 +219,15 @@ module Amortizy
|
|
|
139
219
|
end
|
|
140
220
|
|
|
141
221
|
def grace_period_interest
|
|
142
|
-
return 0.0 if @grace_period_days
|
|
222
|
+
return 0.0 if @grace_period_days.zero?
|
|
223
|
+
|
|
143
224
|
grace_rate = (@annual_rate / 365.0) * @grace_period_days
|
|
144
225
|
initial_principal_with_origination * grace_rate
|
|
145
226
|
end
|
|
146
227
|
|
|
147
228
|
def effective_principal
|
|
148
229
|
base_principal = initial_principal_with_origination + grace_period_interest
|
|
149
|
-
|
|
230
|
+
|
|
150
231
|
case @additional_fee_treatment
|
|
151
232
|
when :add_to_principal
|
|
152
233
|
base_principal + @additional_fee
|
|
@@ -156,7 +237,7 @@ module Amortizy
|
|
|
156
237
|
end
|
|
157
238
|
|
|
158
239
|
def first_payment_date
|
|
159
|
-
if @grace_period_days
|
|
240
|
+
if @grace_period_days.positive?
|
|
160
241
|
grace_end_date = @start_date + @grace_period_days
|
|
161
242
|
next_bank_day(grace_end_date)
|
|
162
243
|
else
|
|
@@ -165,31 +246,31 @@ module Amortizy
|
|
|
165
246
|
end
|
|
166
247
|
|
|
167
248
|
def calculate_payment
|
|
168
|
-
|
|
169
|
-
principal_payments =
|
|
170
|
-
|
|
249
|
+
num = total_payments
|
|
250
|
+
principal_payments = num - @interest_only_periods
|
|
251
|
+
|
|
171
252
|
if @interest_method == :precomputed
|
|
172
253
|
principal_payment_portion = effective_principal / principal_payments
|
|
173
254
|
interest_portion = precomputed_interest_per_payment
|
|
174
|
-
|
|
255
|
+
|
|
175
256
|
if @additional_fee_treatment == :distributed
|
|
176
|
-
principal_payment_portion + interest_portion + (@additional_fee /
|
|
257
|
+
principal_payment_portion + interest_portion + (@additional_fee / num)
|
|
177
258
|
else
|
|
178
259
|
principal_payment_portion + interest_portion
|
|
179
260
|
end
|
|
180
261
|
else
|
|
181
262
|
days_per_period = calculate_average_days_per_period
|
|
182
263
|
period_rate = (@annual_rate / 365.0) * days_per_period
|
|
183
|
-
|
|
184
|
-
base_payment = if period_rate
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
264
|
+
|
|
265
|
+
base_payment = if period_rate.zero?
|
|
266
|
+
effective_principal / principal_payments
|
|
267
|
+
else
|
|
268
|
+
effective_principal * (period_rate * ((1 + period_rate)**principal_payments)) /
|
|
269
|
+
(((1 + period_rate)**principal_payments) - 1)
|
|
270
|
+
end
|
|
190
271
|
|
|
191
272
|
if @additional_fee_treatment == :distributed
|
|
192
|
-
base_payment + (@additional_fee /
|
|
273
|
+
base_payment + (@additional_fee / num)
|
|
193
274
|
else
|
|
194
275
|
base_payment
|
|
195
276
|
end
|
|
@@ -205,30 +286,41 @@ module Amortizy
|
|
|
205
286
|
return true unless @bank_days_only
|
|
206
287
|
return false if date.saturday? || date.sunday?
|
|
207
288
|
return false if federal_holiday?(date)
|
|
289
|
+
|
|
208
290
|
true
|
|
209
291
|
end
|
|
210
292
|
|
|
211
293
|
def next_bank_day(date)
|
|
212
294
|
return date unless @bank_days_only
|
|
213
|
-
|
|
295
|
+
|
|
214
296
|
current = date
|
|
215
|
-
until bank_day?(current)
|
|
216
|
-
current += 1
|
|
217
|
-
end
|
|
297
|
+
current += 1 until bank_day?(current)
|
|
218
298
|
current
|
|
219
299
|
end
|
|
220
300
|
|
|
221
301
|
def next_payment_date(current_date)
|
|
222
|
-
case @frequency
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
302
|
+
next_date = case @frequency
|
|
303
|
+
when :daily
|
|
304
|
+
current_date + 1
|
|
305
|
+
when :weekly
|
|
306
|
+
current_date + 7
|
|
307
|
+
when :biweekly
|
|
308
|
+
current_date + 14
|
|
309
|
+
when :monthly
|
|
310
|
+
advance_by_month(current_date)
|
|
311
|
+
end
|
|
312
|
+
|
|
229
313
|
next_bank_day(next_date)
|
|
230
314
|
end
|
|
231
315
|
|
|
316
|
+
def advance_by_month(date)
|
|
317
|
+
target_year = date.month == 12 ? date.year + 1 : date.year
|
|
318
|
+
target_month = date.month == 12 ? 1 : date.month + 1
|
|
319
|
+
last_day = Date.new(target_year, target_month, -1).day
|
|
320
|
+
target_day = [date.day, last_day].min
|
|
321
|
+
Date.new(target_year, target_month, target_day)
|
|
322
|
+
end
|
|
323
|
+
|
|
232
324
|
def calculate_days_between(start_date, end_date)
|
|
233
325
|
(end_date - start_date).to_i
|
|
234
326
|
end
|
|
@@ -240,10 +332,10 @@ module Amortizy
|
|
|
240
332
|
payment_date = first_payment_date
|
|
241
333
|
previous_payment_date = first_payment_date
|
|
242
334
|
payment_number = 0
|
|
243
|
-
|
|
335
|
+
num = total_payments
|
|
244
336
|
schedule_data = []
|
|
245
337
|
|
|
246
|
-
if @grace_period_days
|
|
338
|
+
if @grace_period_days.positive?
|
|
247
339
|
grace_interest = grace_period_interest
|
|
248
340
|
schedule_data << {
|
|
249
341
|
payment_number: 'Grace',
|
|
@@ -261,7 +353,7 @@ module Amortizy
|
|
|
261
353
|
}
|
|
262
354
|
end
|
|
263
355
|
|
|
264
|
-
if @additional_fee_treatment == :separate_payment && @additional_fee
|
|
356
|
+
if @additional_fee_treatment == :separate_payment && @additional_fee.positive?
|
|
265
357
|
payment_date = next_payment_date(payment_date)
|
|
266
358
|
schedule_data << {
|
|
267
359
|
payment_number: 0,
|
|
@@ -279,38 +371,49 @@ module Amortizy
|
|
|
279
371
|
previous_payment_date = payment_date
|
|
280
372
|
end
|
|
281
373
|
|
|
282
|
-
additional_fee_per_payment = @additional_fee_treatment == :distributed ? (@additional_fee /
|
|
374
|
+
additional_fee_per_payment = @additional_fee_treatment == :distributed ? (@additional_fee / num) : 0.0
|
|
283
375
|
precomputed_interest = @interest_method == :precomputed ? precomputed_interest_per_payment : 0.0
|
|
284
376
|
|
|
285
|
-
|
|
377
|
+
# For monthly frequency, track the target date separately from the actual
|
|
378
|
+
# payment date to prevent bank-day adjustments from compounding drift.
|
|
379
|
+
monthly_target = @frequency == :monthly ? first_payment_date : nil
|
|
380
|
+
|
|
381
|
+
while payment_number < num && balance > 0.01
|
|
286
382
|
payment_number += 1
|
|
287
|
-
|
|
383
|
+
|
|
384
|
+
if @frequency == :monthly && monthly_target
|
|
385
|
+
monthly_target = advance_by_month(monthly_target)
|
|
386
|
+
payment_date = next_bank_day(monthly_target)
|
|
387
|
+
else
|
|
388
|
+
payment_date = next_payment_date(previous_payment_date)
|
|
389
|
+
end
|
|
390
|
+
|
|
288
391
|
days_in_period = calculate_days_between(previous_payment_date, payment_date)
|
|
289
|
-
|
|
392
|
+
|
|
290
393
|
if @interest_method == :precomputed
|
|
291
394
|
interest_payment = precomputed_interest
|
|
292
395
|
else
|
|
293
396
|
period_rate = (@annual_rate / 365.0) * days_in_period
|
|
294
397
|
interest_payment = balance * period_rate
|
|
295
398
|
end
|
|
296
|
-
|
|
399
|
+
|
|
297
400
|
accrued_interest += interest_payment
|
|
298
401
|
is_interest_only = payment_number <= @interest_only_periods
|
|
299
|
-
|
|
402
|
+
|
|
300
403
|
if is_interest_only
|
|
301
404
|
principal_payment = 0.0
|
|
302
405
|
payment_type = 'Interest Only'
|
|
303
406
|
else
|
|
304
407
|
principal_payment = [payment_amount - interest_payment - additional_fee_per_payment, balance].min
|
|
305
|
-
principal_payment = balance if payment_number ==
|
|
408
|
+
principal_payment = balance if payment_number == num
|
|
306
409
|
payment_type = 'Regular Payment'
|
|
307
410
|
end
|
|
308
|
-
|
|
411
|
+
|
|
309
412
|
total_payment = principal_payment + interest_payment + additional_fee_per_payment
|
|
310
413
|
balance -= principal_payment
|
|
311
414
|
balance = 0 if balance < 0.01
|
|
312
415
|
total_balance = balance + accrued_interest
|
|
313
|
-
|
|
416
|
+
|
|
314
417
|
schedule_data << {
|
|
315
418
|
payment_number: payment_number,
|
|
316
419
|
date: payment_date,
|
|
@@ -324,193 +427,12 @@ module Amortizy
|
|
|
324
427
|
payment_type: payment_type,
|
|
325
428
|
days_in_period: days_in_period
|
|
326
429
|
}
|
|
327
|
-
|
|
430
|
+
|
|
328
431
|
accrued_interest = 0.0
|
|
329
432
|
previous_payment_date = payment_date
|
|
330
433
|
end
|
|
331
434
|
|
|
332
435
|
schedule_data
|
|
333
436
|
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
437
|
end
|
|
515
438
|
end
|
|
516
|
-
|
data/lib/amortizy/version.rb
CHANGED
data/lib/amortizy.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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'
|
|
5
7
|
|
|
6
8
|
module Amortizy
|
|
7
9
|
class Error < StandardError; end
|