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.
data/README.md CHANGED
@@ -1,43 +1,519 @@
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
+ - **Commercial financing disclosures**: APR (Reg Z actuarial method), finance charge, amount financed, and more
23
+ - **Testing**: 120 RSpec tests
6
24
 
7
25
  ## Installation
8
26
 
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.
27
+ Add this line to your application's Gemfile:
28
+
29
+ ```ruby
30
+ gem 'amortizy'
31
+ ```
32
+
33
+ And then execute:
34
+
35
+ ```bash
36
+ bundle install
37
+ ```
10
38
 
11
- Install the gem and add to the application's Gemfile by executing:
39
+ Or install it yourself as:
12
40
 
13
41
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
42
+ gem install amortizy
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ### Monthly Loan (most common)
48
+
49
+ ```ruby
50
+ require 'amortizy'
51
+
52
+ schedule = Amortizy::AmortizationSchedule.new(
53
+ start_date: "2026-01-15",
54
+ principal: 100_000.00,
55
+ term_months: 12,
56
+ annual_rate: 10.0,
57
+ frequency: :monthly
58
+ )
59
+
60
+ # Display in console
61
+ schedule.generate
62
+
63
+ # Access data programmatically
64
+ schedule.summary # => { start_date:, end_date:, principal:, total_payments:, ... }
65
+ schedule.payment_amount # => 8791.59
66
+ schedule.total_interest # => 5499.06
67
+ schedule.total_paid # => 105499.06
68
+ schedule.end_date # => #<Date: 2027-01-15>
69
+
70
+ # Get the full schedule as an array of hashes
71
+ schedule.schedule.each do |payment|
72
+ puts "#{payment[:date]} - $#{'%.2f' % payment[:total_payment]}"
73
+ end
74
+
75
+ # Generate CSV file
76
+ schedule.generate(output: :csv, csv_path: "schedule.csv")
77
+ ```
78
+
79
+ ### Specify Exact Payment Count
80
+
81
+ Instead of a term length, tell the gem exactly how many payments you want:
82
+
83
+ ```ruby
84
+ schedule = Amortizy::AmortizationSchedule.new(
85
+ start_date: "2026-01-15",
86
+ principal: 50_000.00,
87
+ num_payments: 26,
88
+ annual_rate: 12.0,
89
+ frequency: :biweekly
90
+ )
91
+
92
+ schedule.schedule.length # => 26
93
+ schedule.end_date # => the date of the 26th payment
94
+ schedule.term_months # => nil (not applicable when using num_payments)
95
+ ```
96
+
97
+ ### Advanced Example with All Features
98
+
99
+ ```ruby
100
+ schedule = Amortizy::AmortizationSchedule.new(
101
+ start_date: "2026-01-15",
102
+ principal: 100_000.00,
103
+ term_months: 12,
104
+ annual_rate: 17.75,
105
+ frequency: :monthly,
106
+ origination_fee: 10_000.00,
107
+ additional_fee: 2_500.00,
108
+ additional_fee_label: "Processing Fee",
109
+ additional_fee_treatment: :distributed,
110
+ bank_days_only: true,
111
+ interest_only_periods: 3,
112
+ grace_period_days: 3,
113
+ interest_method: :simple
114
+ )
115
+
116
+ schedule.generate
117
+ ```
118
+
119
+ ## API Reference
120
+
121
+ ### Initialization Parameters
122
+
123
+ | Parameter | Type | Required | Default | Options | Description |
124
+ |-----------|------|----------|---------|---------|-------------|
125
+ | `start_date` | String/Date | Yes | - | YYYY-MM-DD | Loan start date |
126
+ | `principal` | Float | Yes | - | > 0 | Loan principal amount |
127
+ | `term_months` | Integer | One of* | - | Any positive integer | Loan term in months |
128
+ | `num_payments` | Integer | One of* | - | Any positive integer | Exact number of payments |
129
+ | `annual_rate` | Float | Yes | - | - | Annual interest rate (%) |
130
+ | `frequency` | Symbol | Yes | - | `:monthly`, `:biweekly`, `:weekly`, `:daily` | Payment frequency |
131
+ | `origination_fee` | Float | No | 0 | - | Fee added to principal |
132
+ | `additional_fee` | Float | No | 0 | - | Additional processing fee |
133
+ | `additional_fee_label` | String | No | "Additional Fee" | - | Label for additional fee |
134
+ | `additional_fee_treatment` | Symbol | No | `:distributed` | `:distributed`, `:add_to_principal`, `:separate_payment` | How to handle additional fee |
135
+ | `bank_days_only` | Boolean | No | false | - | Skip weekends and holidays |
136
+ | `interest_only_periods` | Integer | No | 0 | - | Number of interest-only payments |
137
+ | `grace_period_days` | Integer | No | 0 | - | Days before first payment |
138
+ | `interest_method` | Symbol | No | `:simple` | `:simple`, `:precomputed` | Interest calculation method |
139
+
140
+ *Provide either `term_months` or `num_payments`, not both.
141
+
142
+ ### Public Methods
143
+
144
+ #### `#generate(output: :console, csv_path: nil)`
145
+
146
+ Render the schedule to console or CSV file.
147
+
148
+ ```ruby
149
+ schedule.generate # console output
150
+ schedule.generate(output: :csv, csv_path: "schedule.csv") # CSV file
151
+ ```
152
+
153
+ #### `#schedule`
154
+
155
+ Returns a frozen array of payment hashes. Each hash contains:
156
+
157
+ ```ruby
158
+ {
159
+ payment_number: 1,
160
+ date: #<Date>,
161
+ principal_payment: 8302.48,
162
+ interest_payment: 489.11,
163
+ additional_fee_payment: 0.0,
164
+ total_payment: 8791.59,
165
+ principal_balance: 91697.52,
166
+ accrued_interest: 489.11,
167
+ total_balance: 92186.63,
168
+ payment_type: "Regular Payment",
169
+ days_in_period: 29
170
+ }
171
+ ```
172
+
173
+ #### `#summary`
174
+
175
+ Returns a hash with loan totals:
176
+
177
+ ```ruby
178
+ {
179
+ start_date: #<Date>,
180
+ end_date: #<Date>,
181
+ principal: 100000.0,
182
+ total_payments: 12,
183
+ frequency: :monthly,
184
+ annual_rate: 10.0,
185
+ payment_amount: 8791.59,
186
+ total_interest: 5499.06,
187
+ total_paid: 105499.06
188
+ }
189
+ ```
190
+
191
+ #### Convenience Methods
192
+
193
+ | Method | Returns |
194
+ |--------|---------|
195
+ | `#end_date` | Date of the last payment |
196
+ | `#total_interest` | Sum of all interest payments |
197
+ | `#total_paid` | Sum of all payments |
198
+ | `#payment_amount` | Regular payment amount |
199
+
200
+ ## Payment Frequencies
201
+
202
+ ### Monthly (`:monthly`)
203
+
204
+ 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).
205
+
206
+ ### Bi-weekly (`:biweekly`)
207
+
208
+ Payments every 14 days. Common for payroll-aligned loan repayment.
209
+
210
+ ### Weekly (`:weekly`)
211
+
212
+ Payments every 7 days.
213
+
214
+ ### Daily (`:daily`)
215
+
216
+ Payments every day. When combined with `bank_days_only: true`, payments skip weekends and federal holidays.
217
+
218
+ ## Fee Treatment Options
219
+
220
+ ### 1. Distributed (`:distributed`)
221
+ The fee is spread evenly across all payments.
222
+
223
+ **Best for:** Keeping individual payments manageable while recovering fees over time
224
+
225
+ ### 2. Add to Principal (`:add_to_principal`)
226
+ The fee is added to the loan principal upfront, increasing the base amount financed.
227
+
228
+ **Best for:** Rolling all costs into the loan amount
229
+
230
+ ### 3. Separate Payment (`:separate_payment`)
231
+ The fee is collected as a separate first payment before regular amortization begins.
232
+
233
+ **Best for:** Collecting fees upfront separately from the loan repayment
234
+
235
+ ## Interest Calculation Methods
236
+
237
+ ### Simple Interest (`:simple`)
238
+
239
+ Interest accrues daily on the remaining principal balance. As you pay down the principal, interest payments decrease over time.
240
+
241
+ **Formula:** Daily interest = (Principal Balance x Annual Rate) / 365
242
+
243
+ **Best for:** Traditional amortizing loans, consumer loans
244
+
245
+ ### Precomputed Interest (`:precomputed`)
246
+
247
+ 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.
248
+
249
+ **Best for:** Fixed payment structures, certain consumer loan types
250
+
251
+ ## Federal Bank Holidays
252
+
253
+ When `bank_days_only: true`, the schedule automatically skips weekends and US Federal Reserve holidays:
254
+
255
+ - New Year's Day, MLK Jr. Day, Presidents' Day, Memorial Day, Juneteenth, Independence Day, Labor Day, Columbus Day, Veterans Day, Thanksgiving, Christmas
256
+
257
+ Weekend observation rules are handled automatically by the `holidays` gem.
258
+
259
+ ## Usage Examples
260
+
261
+ ### 30-Year Mortgage
262
+
263
+ ```ruby
264
+ schedule = Amortizy::AmortizationSchedule.new(
265
+ start_date: "2026-01-15",
266
+ principal: 350_000.00,
267
+ term_months: 360,
268
+ annual_rate: 6.5,
269
+ frequency: :monthly
270
+ )
271
+
272
+ puts "Monthly payment: $#{'%.2f' % schedule.payment_amount}"
273
+ puts "Total interest: $#{'%.2f' % schedule.total_interest}"
274
+ ```
275
+
276
+ ### Weekly Loan with Grace Period
277
+
278
+ ```ruby
279
+ schedule = Amortizy::AmortizationSchedule.new(
280
+ start_date: "2026-01-15",
281
+ principal: 75_000.00,
282
+ term_months: 12,
283
+ annual_rate: 15.0,
284
+ frequency: :weekly,
285
+ grace_period_days: 7
286
+ )
287
+
288
+ schedule.generate
289
+ ```
290
+
291
+ ### Interest-Only with Bank Days
292
+
293
+ ```ruby
294
+ schedule = Amortizy::AmortizationSchedule.new(
295
+ start_date: "2026-01-15",
296
+ principal: 100_000.00,
297
+ term_months: 18,
298
+ annual_rate: 10.0,
299
+ frequency: :weekly,
300
+ interest_only_periods: 8,
301
+ bank_days_only: true
302
+ )
303
+
304
+ schedule.generate
305
+ ```
306
+
307
+ ### Complex Commercial Loan
308
+
309
+ ```ruby
310
+ schedule = Amortizy::AmortizationSchedule.new(
311
+ start_date: "2026-01-15",
312
+ principal: 250_000.00,
313
+ term_months: 18,
314
+ annual_rate: 14.5,
315
+ frequency: :daily,
316
+ origination_fee: 25_000.00,
317
+ additional_fee: 5_000.00,
318
+ additional_fee_label: "Processing Fee",
319
+ additional_fee_treatment: :distributed,
320
+ bank_days_only: true,
321
+ interest_only_periods: 20,
322
+ grace_period_days: 5,
323
+ interest_method: :simple
324
+ )
325
+
326
+ schedule.generate(output: :csv, csv_path: "commercial_loan.csv")
327
+ ```
328
+
329
+ ## Commercial Financing Disclosures
330
+
331
+ Amortizy includes a disclosure module for computing data elements required by California SB 1235 and similar state commercial financing disclosure laws (New York, Georgia, etc.). Values are jurisdiction-agnostic — computed per the most stringent methodology, usable for any state.
332
+
333
+ The `Disclosure` class wraps an existing `AmortizationSchedule` and computes nine disclosure elements. The gem computes the numbers — your application handles document formatting.
334
+
335
+ **Key assumption:** The origination fee is treated as a prepaid finance charge (deducted from amount financed, included in finance charge). This is the standard treatment for commercial lending.
336
+
337
+ ### Basic Usage
338
+
339
+ ```ruby
340
+ schedule = Amortizy::AmortizationSchedule.new(
341
+ start_date: "2026-01-15",
342
+ principal: 50_000,
343
+ annual_rate: 12.0,
344
+ frequency: :monthly,
345
+ term_months: 36,
346
+ origination_fee: 500,
347
+ additional_fee: 250,
348
+ additional_fee_treatment: :distributed
349
+ )
350
+
351
+ disclosure = Amortizy::Disclosure.new(schedule)
352
+
353
+ disclosure.apr # => 12.3 (Reg Z actuarial method, 10 bps precision)
354
+ disclosure.finance_charge # => 11119.65
355
+ disclosure.amount_financed # => 49500.0
356
+ disclosure.total_payment_amount # => 60619.65
357
+ disclosure.recipient_funds # => 49500.0
358
+ disclosure.term_display # => "3 years, 0.20 months"
359
+ disclosure.average_monthly_cost # => nil (monthly frequency)
360
+ disclosure.prepayment # => { has_non_interest_charges: false, ... }
361
+ ```
362
+
363
+ ### With Third-Party Payments and Prepayment Info
364
+
365
+ ```ruby
366
+ disclosure = Amortizy::Disclosure.new(
367
+ schedule,
368
+ third_party_payments: 10_000,
369
+ prepayment_penalty_max: 1_200,
370
+ additional_prepayment_fees: [
371
+ { amount: 250.0, description: "Early termination fee" }
372
+ ]
373
+ )
374
+
375
+ disclosure.recipient_funds # => 39500.0 (amount_financed - third_party_payments)
376
+ disclosure.prepayment
377
+ # => {
378
+ # has_non_interest_charges: true,
379
+ # max_non_interest_finance_charge: 1200.0,
380
+ # has_additional_fees: true,
381
+ # additional_fees: [{ amount: 250.0, description: "Early termination fee" }]
382
+ # }
383
+ ```
384
+
385
+ ### Output Formats
386
+
387
+ ```ruby
388
+ # Raw values
389
+ disclosure.to_h
390
+ # => { amount_financed: 49500.0, apr: 12.3, finance_charge: 11119.65, ... }
391
+
392
+ # With regulation-correct labels (per CA SB 1235 section 910)
393
+ disclosure.to_labeled_h
394
+ # => {
395
+ # amount_financed: { label: "Funding Provided", value: 49500.0 },
396
+ # apr: { label: "Annual Percentage Rate (APR)", value: 12.3 },
397
+ # finance_charge: { label: "Finance Charge", value: 11119.65 },
398
+ # ...
399
+ # }
15
400
  ```
16
401
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
402
+ ### Disclosure Parameters
403
+
404
+ | Parameter | Type | Default | Description |
405
+ |-----------|------|---------|-------------|
406
+ | `third_party_payments` | Float | 0 | Amounts paid to third parties from the financing |
407
+ | `prepayment_penalty_max` | Float | 0 | Maximum non-interest charge on early payoff |
408
+ | `additional_prepayment_fees` | Array | [] | Array of `{ amount:, description: }` hashes |
409
+
410
+ ### Disclosure Elements
411
+
412
+ | Element | Method | Description | Regulatory Reference |
413
+ |---------|--------|-------------|---------------------|
414
+ | Funding Provided | `#amount_financed` | Principal adjusted for prepaid finance charges | §900(a)(1)(B) |
415
+ | APR | `#apr` | Annual percentage rate (Reg Z actuarial method) | §940 |
416
+ | Finance Charge | `#finance_charge` | Total dollar cost (derived from Reg Z identity) | §943 |
417
+ | Total Payment Amount | `#total_payment_amount` | Sum of all borrower payments | §910(a)(5) |
418
+ | Payment | `#payment_amount` | Regular periodic payment | §910(a)(6) |
419
+ | Term | `#term_display` | Days (<=1yr) or years/months (>1yr) | §901(a)(4) |
420
+ | Average Monthly Cost | `#average_monthly_cost` | For non-monthly frequencies only | §910(a)(12) |
421
+ | Recipient Funds | `#recipient_funds` | Net amount to borrower | §900(a)(26) |
422
+ | Prepayment | `#prepayment` | Prepayment penalty details | §910(a)(8-10) |
423
+
424
+ ### Technical Notes
425
+
426
+ - **APR calculation** uses the actuarial method per Appendix J of Regulation Z (12 CFR Part 1026). Newton-Raphson solver with bisection fallback. APR rounded to nearest 10 basis points per §901(a)(5). Accuracy within 1/8 of 1 percentage point per §955.
427
+ - **Finance charge** is derived from the Reg Z identity: `total_payment_amount - amount_financed`. This guarantees the three core disclosure values are always internally consistent.
428
+ - **Floating-point arithmetic** is used for all calculations. This is adequate for typical commercial loans. For extremely large principals or very long terms (1000+ payments), consider validating against a reference APR calculator.
429
+
430
+ ## Command Line Interface
431
+
432
+ Amortizy includes an interactive CLI tool:
18
433
 
19
434
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
435
+ amortizy
21
436
  ```
22
437
 
23
- ## Usage
438
+ The CLI provides:
439
+ 1. Run default examples (demonstrates simple vs precomputed interest)
440
+ 2. Enter parameters manually with interactive prompts
441
+ 3. Automatic CSV generation option
442
+
443
+ ## Requirements
24
444
 
25
- TODO: Write usage instructions here
445
+ - Ruby 3.0 or higher
446
+ - `holidays` gem (~> 8.0) - automatically installed as a dependency
26
447
 
27
448
  ## Development
28
449
 
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.
450
+ After checking out the repo, run:
30
451
 
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).
452
+ ```bash
453
+ # Install dependencies
454
+ bundle install
455
+
456
+ # Run tests
457
+ bundle exec rspec
458
+
459
+ # Run linter
460
+ bundle exec rubocop
461
+
462
+ # Run interactive console
463
+ bin/console
464
+ ```
465
+
466
+ ## Testing
467
+
468
+ The test suite includes 120 RSpec examples covering:
469
+
470
+ - Initialization and validation
471
+ - Payment calculations for all four frequencies
472
+ - Dual input model (term_months vs num_payments)
473
+ - Effective principal calculations
474
+ - Schedule generation and full amortization
475
+ - Interest methods (simple vs precomputed)
476
+ - Grace periods and interest-only periods
477
+ - Bank day functionality and federal holiday detection
478
+ - Fee treatments (distributed, add to principal, separate payment)
479
+ - Monthly date drift prevention with bank_days_only
480
+ - CSV generation
481
+ - Public API (schedule, summary, convenience methods)
482
+ - Deep freeze immutability
483
+ - Edge cases (year boundaries, month-end clamping, input validation)
484
+ - Commercial financing disclosures (APR, finance charge, amount financed, all nine elements)
485
+ - Reg Z identity verification across all fee treatment modes
486
+ - APR solver convergence (grace periods, interest-only, short-term, zero-rate loans)
487
+
488
+ Run the test suite:
489
+
490
+ ```bash
491
+ bundle exec rspec
492
+ ```
32
493
 
33
494
  ## Contributing
34
495
 
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).
496
+ Bug reports and pull requests are welcome on GitHub at https://github.com/zapatify/amortizy.
497
+
498
+ ### How to Contribute
499
+
500
+ 1. Fork the repository
501
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
502
+ 3. Make your changes
503
+ 4. Add tests for new functionality
504
+ 5. Ensure all tests pass (`bundle exec rspec`)
505
+ 6. Commit your changes (`git commit -m 'Add amazing feature'`)
506
+ 7. Push to your branch (`git push origin feature/amazing-feature`)
507
+ 8. Open a Pull Request
36
508
 
37
509
  ## License
38
510
 
39
511
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
512
 
41
- ## Code of Conduct
513
+ ## Changelog
514
+
515
+ See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
516
+
517
+ ## Author
42
518
 
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).
519
+ **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