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.
data/README.md CHANGED
@@ -1,43 +1,414 @@
1
1
  # Amortizy
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ [![Gem Version](https://img.shields.io/gem/v/amortizy.svg?style=flat)](https://rubygems.org/gems/amortizy)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
5
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/amortizy`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+ A Ruby gem for generating loan amortization schedules. Supports monthly, bi-weekly, weekly, and daily payment frequencies with grace periods, interest-only payments, federal bank holidays, and multiple interest calculation methods.
7
+
8
+ Perfect for financial applications, lending platforms, and loan calculators.
9
+
10
+ ## Features
11
+
12
+ - **Multiple payment frequencies**: Monthly, bi-weekly, weekly, or daily payments
13
+ - **Flexible loan terms**: Any term length in months, or specify an exact payment count
14
+ - **Dual input model**: Provide `term_months` (gem calculates payment count) or `num_payments` (you specify exactly how many)
15
+ - **Interest calculation methods**: Simple (accrued daily) or precomputed (fixed per payment)
16
+ - **Grace periods**: Grace periods before first payment (interest accrues and capitalizes)
17
+ - **Interest-only periods**: Configure initial interest-only payment phases
18
+ - **Fee handling**: Origination fees and additional fees with three treatment options
19
+ - **Bank day calculations**: Automatically skip weekends and US Federal Reserve holidays
20
+ - **Multiple output formats**: Console display or CSV export
21
+ - **Public API**: Programmatic access to schedule data, summaries, and totals
22
+ - **Testing**: 70 RSpec tests
6
23
 
7
24
  ## Installation
8
25
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
26
+ Add this line to your application's Gemfile:
10
27
 
11
- Install the gem and add to the application's Gemfile by executing:
28
+ ```ruby
29
+ gem 'amortizy'
30
+ ```
31
+
32
+ And then execute:
12
33
 
13
34
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
35
+ bundle install
15
36
  ```
16
37
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
38
+ Or install it yourself as:
18
39
 
19
40
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
41
+ gem install amortizy
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ ### Monthly Loan (most common)
47
+
48
+ ```ruby
49
+ require 'amortizy'
50
+
51
+ schedule = Amortizy::AmortizationSchedule.new(
52
+ start_date: "2026-01-15",
53
+ principal: 100_000.00,
54
+ term_months: 12,
55
+ annual_rate: 10.0,
56
+ frequency: :monthly
57
+ )
58
+
59
+ # Display in console
60
+ schedule.generate
61
+
62
+ # Access data programmatically
63
+ schedule.summary # => { start_date:, end_date:, principal:, total_payments:, ... }
64
+ schedule.payment_amount # => 8791.59
65
+ schedule.total_interest # => 5499.06
66
+ schedule.total_paid # => 105499.06
67
+ schedule.end_date # => #<Date: 2027-01-15>
68
+
69
+ # Get the full schedule as an array of hashes
70
+ schedule.schedule.each do |payment|
71
+ puts "#{payment[:date]} - $#{'%.2f' % payment[:total_payment]}"
72
+ end
73
+
74
+ # Generate CSV file
75
+ schedule.generate(output: :csv, csv_path: "schedule.csv")
21
76
  ```
22
77
 
23
- ## Usage
78
+ ### Specify Exact Payment Count
79
+
80
+ Instead of a term length, tell the gem exactly how many payments you want:
81
+
82
+ ```ruby
83
+ schedule = Amortizy::AmortizationSchedule.new(
84
+ start_date: "2026-01-15",
85
+ principal: 50_000.00,
86
+ num_payments: 26,
87
+ annual_rate: 12.0,
88
+ frequency: :biweekly
89
+ )
90
+
91
+ schedule.schedule.length # => 26
92
+ schedule.end_date # => the date of the 26th payment
93
+ schedule.term_months # => nil (not applicable when using num_payments)
94
+ ```
95
+
96
+ ### Advanced Example with All Features
97
+
98
+ ```ruby
99
+ schedule = Amortizy::AmortizationSchedule.new(
100
+ start_date: "2026-01-15",
101
+ principal: 100_000.00,
102
+ term_months: 12,
103
+ annual_rate: 17.75,
104
+ frequency: :monthly,
105
+ origination_fee: 10_000.00,
106
+ additional_fee: 2_500.00,
107
+ additional_fee_label: "Processing Fee",
108
+ additional_fee_treatment: :distributed,
109
+ bank_days_only: true,
110
+ interest_only_periods: 3,
111
+ grace_period_days: 3,
112
+ interest_method: :simple
113
+ )
114
+
115
+ schedule.generate
116
+ ```
117
+
118
+ ## API Reference
119
+
120
+ ### Initialization Parameters
121
+
122
+ | Parameter | Type | Required | Default | Options | Description |
123
+ |-----------|------|----------|---------|---------|-------------|
124
+ | `start_date` | String/Date | Yes | - | YYYY-MM-DD | Loan start date |
125
+ | `principal` | Float | Yes | - | > 0 | Loan principal amount |
126
+ | `term_months` | Integer | One of* | - | Any positive integer | Loan term in months |
127
+ | `num_payments` | Integer | One of* | - | Any positive integer | Exact number of payments |
128
+ | `annual_rate` | Float | Yes | - | - | Annual interest rate (%) |
129
+ | `frequency` | Symbol | Yes | - | `:monthly`, `:biweekly`, `:weekly`, `:daily` | Payment frequency |
130
+ | `origination_fee` | Float | No | 0 | - | Fee added to principal |
131
+ | `additional_fee` | Float | No | 0 | - | Additional processing fee |
132
+ | `additional_fee_label` | String | No | "Additional Fee" | - | Label for additional fee |
133
+ | `additional_fee_treatment` | Symbol | No | `:distributed` | `:distributed`, `:add_to_principal`, `:separate_payment` | How to handle additional fee |
134
+ | `bank_days_only` | Boolean | No | false | - | Skip weekends and holidays |
135
+ | `interest_only_periods` | Integer | No | 0 | - | Number of interest-only payments |
136
+ | `grace_period_days` | Integer | No | 0 | - | Days before first payment |
137
+ | `interest_method` | Symbol | No | `:simple` | `:simple`, `:precomputed` | Interest calculation method |
138
+
139
+ *Provide either `term_months` or `num_payments`, not both.
140
+
141
+ ### Public Methods
142
+
143
+ #### `#generate(output: :console, csv_path: nil)`
144
+
145
+ Render the schedule to console or CSV file.
146
+
147
+ ```ruby
148
+ schedule.generate # console output
149
+ schedule.generate(output: :csv, csv_path: "schedule.csv") # CSV file
150
+ ```
151
+
152
+ #### `#schedule`
153
+
154
+ Returns a frozen array of payment hashes. Each hash contains:
155
+
156
+ ```ruby
157
+ {
158
+ payment_number: 1,
159
+ date: #<Date>,
160
+ principal_payment: 8302.48,
161
+ interest_payment: 489.11,
162
+ additional_fee_payment: 0.0,
163
+ total_payment: 8791.59,
164
+ principal_balance: 91697.52,
165
+ accrued_interest: 489.11,
166
+ total_balance: 92186.63,
167
+ payment_type: "Regular Payment",
168
+ days_in_period: 29
169
+ }
170
+ ```
171
+
172
+ #### `#summary`
173
+
174
+ Returns a hash with loan totals:
175
+
176
+ ```ruby
177
+ {
178
+ start_date: #<Date>,
179
+ end_date: #<Date>,
180
+ principal: 100000.0,
181
+ total_payments: 12,
182
+ frequency: :monthly,
183
+ annual_rate: 10.0,
184
+ payment_amount: 8791.59,
185
+ total_interest: 5499.06,
186
+ total_paid: 105499.06
187
+ }
188
+ ```
189
+
190
+ #### Convenience Methods
191
+
192
+ | Method | Returns |
193
+ |--------|---------|
194
+ | `#end_date` | Date of the last payment |
195
+ | `#total_interest` | Sum of all interest payments |
196
+ | `#total_paid` | Sum of all payments |
197
+ | `#payment_amount` | Regular payment amount |
198
+
199
+ ## Payment Frequencies
200
+
201
+ ### Monthly (`:monthly`)
202
+
203
+ Payments on the same day each month. If the start date is the 31st, payments on shorter months use the last day of the month (e.g., Jan 31 -> Feb 28 -> Mar 28).
204
+
205
+ ### Bi-weekly (`:biweekly`)
206
+
207
+ Payments every 14 days. Common for payroll-aligned loan repayment.
208
+
209
+ ### Weekly (`:weekly`)
210
+
211
+ Payments every 7 days.
212
+
213
+ ### Daily (`:daily`)
214
+
215
+ Payments every day. When combined with `bank_days_only: true`, payments skip weekends and federal holidays.
216
+
217
+ ## Fee Treatment Options
218
+
219
+ ### 1. Distributed (`:distributed`)
220
+ The fee is spread evenly across all payments.
221
+
222
+ **Best for:** Keeping individual payments manageable while recovering fees over time
223
+
224
+ ### 2. Add to Principal (`:add_to_principal`)
225
+ The fee is added to the loan principal upfront, increasing the base amount financed.
226
+
227
+ **Best for:** Rolling all costs into the loan amount
24
228
 
25
- TODO: Write usage instructions here
229
+ ### 3. Separate Payment (`:separate_payment`)
230
+ The fee is collected as a separate first payment before regular amortization begins.
231
+
232
+ **Best for:** Collecting fees upfront separately from the loan repayment
233
+
234
+ ## Interest Calculation Methods
235
+
236
+ ### Simple Interest (`:simple`)
237
+
238
+ Interest accrues daily on the remaining principal balance. As you pay down the principal, interest payments decrease over time.
239
+
240
+ **Formula:** Daily interest = (Principal Balance x Annual Rate) / 365
241
+
242
+ **Best for:** Traditional amortizing loans, consumer loans
243
+
244
+ ### Precomputed Interest (`:precomputed`)
245
+
246
+ Total interest is calculated upfront based on the original principal and divided equally across all payments. Interest per payment stays constant regardless of principal reduction.
247
+
248
+ **Best for:** Fixed payment structures, certain consumer loan types
249
+
250
+ ## Federal Bank Holidays
251
+
252
+ When `bank_days_only: true`, the schedule automatically skips weekends and US Federal Reserve holidays:
253
+
254
+ - New Year's Day, MLK Jr. Day, Presidents' Day, Memorial Day, Juneteenth, Independence Day, Labor Day, Columbus Day, Veterans Day, Thanksgiving, Christmas
255
+
256
+ Weekend observation rules are handled automatically by the `holidays` gem.
257
+
258
+ ## Usage Examples
259
+
260
+ ### 30-Year Mortgage
261
+
262
+ ```ruby
263
+ schedule = Amortizy::AmortizationSchedule.new(
264
+ start_date: "2026-01-15",
265
+ principal: 350_000.00,
266
+ term_months: 360,
267
+ annual_rate: 6.5,
268
+ frequency: :monthly
269
+ )
270
+
271
+ puts "Monthly payment: $#{'%.2f' % schedule.payment_amount}"
272
+ puts "Total interest: $#{'%.2f' % schedule.total_interest}"
273
+ ```
274
+
275
+ ### Weekly Loan with Grace Period
276
+
277
+ ```ruby
278
+ schedule = Amortizy::AmortizationSchedule.new(
279
+ start_date: "2026-01-15",
280
+ principal: 75_000.00,
281
+ term_months: 12,
282
+ annual_rate: 15.0,
283
+ frequency: :weekly,
284
+ grace_period_days: 7
285
+ )
286
+
287
+ schedule.generate
288
+ ```
289
+
290
+ ### Interest-Only with Bank Days
291
+
292
+ ```ruby
293
+ schedule = Amortizy::AmortizationSchedule.new(
294
+ start_date: "2026-01-15",
295
+ principal: 100_000.00,
296
+ term_months: 18,
297
+ annual_rate: 10.0,
298
+ frequency: :weekly,
299
+ interest_only_periods: 8,
300
+ bank_days_only: true
301
+ )
302
+
303
+ schedule.generate
304
+ ```
305
+
306
+ ### Complex Commercial Loan
307
+
308
+ ```ruby
309
+ schedule = Amortizy::AmortizationSchedule.new(
310
+ start_date: "2026-01-15",
311
+ principal: 250_000.00,
312
+ term_months: 18,
313
+ annual_rate: 14.5,
314
+ frequency: :daily,
315
+ origination_fee: 25_000.00,
316
+ additional_fee: 5_000.00,
317
+ additional_fee_label: "Processing Fee",
318
+ additional_fee_treatment: :distributed,
319
+ bank_days_only: true,
320
+ interest_only_periods: 20,
321
+ grace_period_days: 5,
322
+ interest_method: :simple
323
+ )
324
+
325
+ schedule.generate(output: :csv, csv_path: "commercial_loan.csv")
326
+ ```
327
+
328
+ ## Command Line Interface
329
+
330
+ Amortizy includes an interactive CLI tool:
331
+
332
+ ```bash
333
+ amortizy
334
+ ```
335
+
336
+ The CLI provides:
337
+ 1. Run default examples (demonstrates simple vs precomputed interest)
338
+ 2. Enter parameters manually with interactive prompts
339
+ 3. Automatic CSV generation option
340
+
341
+ ## Requirements
342
+
343
+ - Ruby 3.0 or higher
344
+ - `holidays` gem (~> 8.0) - automatically installed as a dependency
26
345
 
27
346
  ## Development
28
347
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
348
+ After checking out the repo, run:
349
+
350
+ ```bash
351
+ # Install dependencies
352
+ bundle install
353
+
354
+ # Run tests
355
+ bundle exec rspec
30
356
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
357
+ # Run linter
358
+ bundle exec rubocop
359
+
360
+ # Run interactive console
361
+ bin/console
362
+ ```
363
+
364
+ ## Testing
365
+
366
+ The test suite includes 70 RSpec examples covering:
367
+
368
+ - Initialization and validation
369
+ - Payment calculations for all four frequencies
370
+ - Dual input model (term_months vs num_payments)
371
+ - Effective principal calculations
372
+ - Schedule generation and full amortization
373
+ - Interest methods (simple vs precomputed)
374
+ - Grace periods and interest-only periods
375
+ - Bank day functionality and federal holiday detection
376
+ - Fee treatments (distributed, add to principal, separate payment)
377
+ - Monthly date drift prevention with bank_days_only
378
+ - CSV generation
379
+ - Public API (schedule, summary, convenience methods)
380
+ - Deep freeze immutability
381
+ - Edge cases (year boundaries, month-end clamping, input validation)
382
+
383
+ Run the test suite:
384
+
385
+ ```bash
386
+ bundle exec rspec
387
+ ```
32
388
 
33
389
  ## Contributing
34
390
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/amortizy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/amortizy/blob/main/CODE_OF_CONDUCT.md).
391
+ Bug reports and pull requests are welcome on GitHub at https://github.com/zapatify/amortizy.
392
+
393
+ ### How to Contribute
394
+
395
+ 1. Fork the repository
396
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
397
+ 3. Make your changes
398
+ 4. Add tests for new functionality
399
+ 5. Ensure all tests pass (`bundle exec rspec`)
400
+ 6. Commit your changes (`git commit -m 'Add amazing feature'`)
401
+ 7. Push to your branch (`git push origin feature/amazing-feature`)
402
+ 8. Open a Pull Request
36
403
 
37
404
  ## License
38
405
 
39
406
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
407
 
41
- ## Code of Conduct
408
+ ## Changelog
409
+
410
+ See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
411
+
412
+ ## Author
42
413
 
43
- Everyone interacting in the Amortizy project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/amortizy/blob/main/CODE_OF_CONDUCT.md).
414
+ **Rich Zapata** - [@zapatify](https://github.com/zapatify)
data/Rakefile CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- require "rubocop/rake_task"
8
+ require 'rubocop/rake_task'
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amortizy
4
+ class ConsoleFormatter
5
+ def initialize(schedule_obj)
6
+ @schedule_obj = schedule_obj
7
+ @data = schedule_obj.schedule
8
+ end
9
+
10
+ def render
11
+ puts header
12
+ puts '-' * 195
13
+
14
+ @data.each do |row|
15
+ puts row[:payment_type] == 'Grace Period' ? grace_row(row) : payment_row(row)
16
+ end
17
+
18
+ print_summary
19
+ end
20
+
21
+ private
22
+
23
+ def header
24
+ format(
25
+ '%-8s %-12s %-8s %15s %15s %18s %18s %20s %18s %18s %20s',
26
+ 'Payment', 'Date', 'Days', 'Principal Pmt', 'Interest Pmt',
27
+ @schedule_obj.additional_fee_label, 'Total Payment', 'Principal Balance',
28
+ 'Accrued Interest', 'Total Balance', 'Payment Type'
29
+ )
30
+ end
31
+
32
+ def grace_row(row)
33
+ format(
34
+ '%-8s %-12s %-8d %15s %15s %18s %18s %20.2f %18s %18.2f %20s',
35
+ 'Grace', row[:date].strftime('%Y-%m-%d'), row[:days_in_period],
36
+ '---', "+#{format('%.2f', row[:grace_interest_capitalized])}",
37
+ '---', '0.00', row[:principal_balance], '---',
38
+ row[:principal_balance], 'Grace Period'
39
+ )
40
+ end
41
+
42
+ def payment_row(row)
43
+ label = row[:payment_number].is_a?(Integer) && row[:payment_number].zero? ? 'Fee' : row[:payment_number].to_s
44
+ format(
45
+ '%-8s %-12s %-8d %15.2f %15.2f %18.2f %18.2f %20.2f %18.2f %18.2f %20s',
46
+ label, row[:date].strftime('%Y-%m-%d'), row[:days_in_period],
47
+ row[:principal_payment], row[:interest_payment], row[:additional_fee_payment],
48
+ row[:total_payment], row[:principal_balance], row[:accrued_interest],
49
+ row[:total_balance], row[:payment_type]
50
+ )
51
+ end
52
+
53
+ def print_summary
54
+ summary = @schedule_obj.summary
55
+ puts "\n#{'=' * 195}"
56
+ puts 'LOAN SUMMARY'
57
+ puts '=' * 195
58
+ puts "Loan Start Date: #{summary[:start_date].strftime('%Y-%m-%d')}"
59
+ puts "End Date: #{summary[:end_date].strftime('%Y-%m-%d')}"
60
+ puts "Total Payments: #{summary[:total_payments]}"
61
+ puts "Payment Frequency: #{summary[:frequency].to_s.capitalize}"
62
+ puts "Annual Rate: #{format('%.2f', summary[:annual_rate])}%"
63
+ puts "\nOriginal Principal: $#{format('%.2f', summary[:principal])}"
64
+ puts "Regular Payment: $#{format('%.2f', summary[:payment_amount])}"
65
+ puts "Total Interest Paid: $#{format('%.2f', summary[:total_interest])}"
66
+ puts "Total Amount Paid: $#{format('%.2f', summary[:total_paid])}"
67
+ puts '=' * 195
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+
5
+ module Amortizy
6
+ class CsvFormatter
7
+ HEADERS = [
8
+ 'Payment Number', 'Date', 'Days in Period', 'Principal Payment',
9
+ 'Interest Payment', 'Additional Fee Payment', 'Total Payment',
10
+ 'Principal Balance Remaining', 'Accrued Interest', 'Total Balance',
11
+ 'Payment Type', 'Grace Interest Capitalized'
12
+ ].freeze
13
+
14
+ def initialize(schedule_obj)
15
+ @data = schedule_obj.schedule
16
+ end
17
+
18
+ def render(csv_path)
19
+ CSV.open(csv_path, 'w') do |csv|
20
+ csv << HEADERS
21
+
22
+ @data.each do |row|
23
+ csv << [
24
+ row[:payment_number],
25
+ row[:date].strftime('%Y-%m-%d'),
26
+ row[:days_in_period],
27
+ format('%.2f', row[:principal_payment] || 0),
28
+ format('%.2f', row[:interest_payment] || 0),
29
+ format('%.2f', row[:additional_fee_payment] || 0),
30
+ format('%.2f', row[:total_payment] || 0),
31
+ format('%.2f', row[:principal_balance]),
32
+ format('%.2f', row[:accrued_interest] || 0),
33
+ format('%.2f', row[:total_balance]),
34
+ row[:payment_type],
35
+ row[:grace_interest_capitalized] ? format('%.2f', row[:grace_interest_capitalized]) : ''
36
+ ]
37
+ end
38
+ end
39
+
40
+ puts "CSV file generated: #{csv_path}"
41
+ end
42
+ end
43
+ end