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/README.md
CHANGED
|
@@ -1,43 +1,519 @@
|
|
|
1
1
|
# Amortizy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://rubygems.org/gems/amortizy)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
Or install it yourself as:
|
|
12
40
|
|
|
13
41
|
```bash
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
+
amortizy
|
|
21
436
|
```
|
|
22
437
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
450
|
+
After checking out the repo, run:
|
|
30
451
|
|
|
31
|
-
|
|
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/
|
|
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
|
-
##
|
|
513
|
+
## Changelog
|
|
514
|
+
|
|
515
|
+
See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
|
|
516
|
+
|
|
517
|
+
## Author
|
|
42
518
|
|
|
43
|
-
|
|
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
|
|
4
|
-
require
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
|
-
require
|
|
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
|