amortizy 2.1.0 → 3.1.2
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 +38 -0
- data/README.md +96 -46
- data/lib/amortizy/console_formatter.rb +1 -0
- data/lib/amortizy/disclosure.rb +12 -2
- data/lib/amortizy/engine.rb +98 -31
- data/lib/amortizy/version.rb +1 -1
- metadata +6 -7
- data/tasks/todo.md +0 -203
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 580f55e8a6cadb029944b8fec559b2f280cbcee6c5452f2c132dd3fa314c0cec
|
|
4
|
+
data.tar.gz: b3c68e7a5f0f9c35536754c1a4e665cef3ae814b358128e59747be1d735ca2de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 05a7f4cb9713cefefa2036f9cc6a0b906601b3bf4d143cb1b76338c040f3b804c313f25a46dd4bf88992bb97883b42842b5729815d77d1c12c24ce64f2372cc4
|
|
7
|
+
data.tar.gz: aa4e32281172160851e5c84d364229ef18e8e4a7e07f1849022cff3734e64db6fc7068f1ea301c1bd0eaae0c2ee95fd0ca40c1a0f3d58be019ae7203de4f3fa0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.1.2] - 2026-10-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- `additional_fee_treatment: :separate_payment` no longer gives the borrower an interest-free period (#13). Interest accrues from funding, so the first regular payment includes the fee period's interest, on every frequency. Precomputed interest also counts the fee period
|
|
12
|
+
- Monthly loans with a separate fee now put payment 1 one month after the fee instead of on the fee date, matching the other frequencies. These loans now mature one month later (e.g. `term_months: 12` ends in month 13)
|
|
13
|
+
|
|
14
|
+
## [3.1.1] - 2026-10-21
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- README example outputs now match what the gem returns (Quick Start, `#schedule`, `#summary`, disclosure APR and term)
|
|
18
|
+
- README no longer documents an `amortizy` command-line tool, which the gem has never shipped
|
|
19
|
+
- The packaged gem no longer includes internal planning notes (`tasks/`)
|
|
20
|
+
- README notes the known origination-fee double count in disclosures (#11) and the Saturday holiday issue (#8)
|
|
21
|
+
|
|
22
|
+
## [3.1.0] - 2026-10-21
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- `balloon_amount:` leaves a lump sum of principal due with the final payment (#3)
|
|
26
|
+
- The final row of a balloon loan has `payment_type: "Balloon Payment"`
|
|
27
|
+
- `#balloon_amount` reader and `balloon_amount` in `#summary`
|
|
28
|
+
- Console summary shows the balloon when there is one
|
|
29
|
+
- `Disclosure#balloon_payment`, and `balloon_payment` in `#to_h` / `#to_labeled_h` for balloon loans
|
|
30
|
+
|
|
31
|
+
## [3.0.1] - 2026-10-21
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- Simple-interest payments are sized on each period's actual days, so the final payment matches the regular payment and long or bank-day schedules no longer end early (#12). Simple-interest payment amounts change slightly as a result.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- README now explains negative amortization: at high rates, a long period's interest can be more than the level payment, which shows as a negative `principal_payment`
|
|
38
|
+
|
|
39
|
+
## [3.0.0] - 2026-10-21
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
- Requires Ruby 3.3 or newer (3.0, 3.1 and 3.2 are end-of-life)
|
|
43
|
+
- `holidays` dependency updated from 8.x to 11.7; holiday dates are unchanged
|
|
44
|
+
- CI now tests Ruby 3.3, 3.4 and 4.0
|
|
45
|
+
|
|
8
46
|
## [2.1.0] - 2026-04-20
|
|
9
47
|
|
|
10
48
|
### Added
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://rubygems.org/gems/amortizy)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
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.
|
|
6
|
+
A Ruby gem for generating loan amortization schedules. Supports monthly, bi-weekly, weekly, and daily payment frequencies with grace periods, interest-only payments, balloon payments, federal bank holidays, and multiple interest calculation methods.
|
|
7
7
|
|
|
8
8
|
Perfect for financial applications, lending platforms, and loan calculators.
|
|
9
9
|
|
|
@@ -15,12 +15,13 @@ Perfect for financial applications, lending platforms, and loan calculators.
|
|
|
15
15
|
- **Interest calculation methods**: Simple (accrued daily) or precomputed (fixed per payment)
|
|
16
16
|
- **Grace periods**: Grace periods before first payment (interest accrues and capitalizes)
|
|
17
17
|
- **Interest-only periods**: Configure initial interest-only payment phases
|
|
18
|
+
- **Balloon payments**: Leave a lump sum due with the final payment, common in commercial lending
|
|
18
19
|
- **Fee handling**: Origination fees and additional fees with three treatment options
|
|
19
20
|
- **Bank day calculations**: Automatically skip weekends and US Federal Reserve holidays
|
|
20
21
|
- **Multiple output formats**: Console display or CSV export
|
|
21
22
|
- **Public API**: Programmatic access to schedule data, summaries, and totals
|
|
22
23
|
- **Commercial financing disclosures**: APR (Reg Z actuarial method), finance charge, amount financed, and more
|
|
23
|
-
- **
|
|
24
|
+
- **Tested**: RSpec suite covering every option above
|
|
24
25
|
|
|
25
26
|
## Installation
|
|
26
27
|
|
|
@@ -62,9 +63,9 @@ schedule.generate
|
|
|
62
63
|
|
|
63
64
|
# Access data programmatically
|
|
64
65
|
schedule.summary # => { start_date:, end_date:, principal:, total_payments:, ... }
|
|
65
|
-
schedule.payment_amount # =>
|
|
66
|
-
schedule.total_interest # =>
|
|
67
|
-
schedule.total_paid # =>
|
|
66
|
+
schedule.payment_amount # => 8789.68
|
|
67
|
+
schedule.total_interest # => 5476.15
|
|
68
|
+
schedule.total_paid # => 105476.15
|
|
68
69
|
schedule.end_date # => #<Date: 2027-01-15>
|
|
69
70
|
|
|
70
71
|
# Get the full schedule as an array of hashes
|
|
@@ -94,7 +95,7 @@ schedule.end_date # => the date of the 26th payment
|
|
|
94
95
|
schedule.term_months # => nil (not applicable when using num_payments)
|
|
95
96
|
```
|
|
96
97
|
|
|
97
|
-
### Advanced Example
|
|
98
|
+
### Advanced Example
|
|
98
99
|
|
|
99
100
|
```ruby
|
|
100
101
|
schedule = Amortizy::AmortizationSchedule.new(
|
|
@@ -136,6 +137,7 @@ schedule.generate
|
|
|
136
137
|
| `interest_only_periods` | Integer | No | 0 | - | Number of interest-only payments |
|
|
137
138
|
| `grace_period_days` | Integer | No | 0 | - | Days before first payment |
|
|
138
139
|
| `interest_method` | Symbol | No | `:simple` | `:simple`, `:precomputed` | Interest calculation method |
|
|
140
|
+
| `balloon_amount` | Numeric or decimal String | No | 0 | >= 0, less than the amortized balance; other text raises `ArgumentError` | Principal left unpaid until the final payment. See [Balloon Payments](#balloon-payments) |
|
|
139
141
|
|
|
140
142
|
*Provide either `term_months` or `num_payments`, not both.
|
|
141
143
|
|
|
@@ -157,19 +159,23 @@ Returns a frozen array of payment hashes. Each hash contains:
|
|
|
157
159
|
```ruby
|
|
158
160
|
{
|
|
159
161
|
payment_number: 1,
|
|
160
|
-
date: #<Date>,
|
|
161
|
-
principal_payment:
|
|
162
|
-
interest_payment:
|
|
162
|
+
date: #<Date: 2026-02-15>,
|
|
163
|
+
principal_payment: 7940.36,
|
|
164
|
+
interest_payment: 849.32,
|
|
163
165
|
additional_fee_payment: 0.0,
|
|
164
|
-
total_payment:
|
|
165
|
-
principal_balance:
|
|
166
|
-
accrued_interest:
|
|
167
|
-
total_balance:
|
|
166
|
+
total_payment: 8789.68,
|
|
167
|
+
principal_balance: 92059.64,
|
|
168
|
+
accrued_interest: 849.32,
|
|
169
|
+
total_balance: 92908.95,
|
|
168
170
|
payment_type: "Regular Payment",
|
|
169
|
-
days_in_period:
|
|
171
|
+
days_in_period: 31
|
|
170
172
|
}
|
|
171
173
|
```
|
|
172
174
|
|
|
175
|
+
`payment_type` is one of `"Regular Payment"`, `"Interest Only"`, `"Balloon Payment"` (the final row of a
|
|
176
|
+
balloon loan), `"Additional Fee Payment"` (row `0`, with `:separate_payment`) or `"Grace Period"` (row
|
|
177
|
+
`"Grace"`, which also carries `grace_interest_capitalized`).
|
|
178
|
+
|
|
173
179
|
#### `#summary`
|
|
174
180
|
|
|
175
181
|
Returns a hash with loan totals:
|
|
@@ -182,9 +188,10 @@ Returns a hash with loan totals:
|
|
|
182
188
|
total_payments: 12,
|
|
183
189
|
frequency: :monthly,
|
|
184
190
|
annual_rate: 10.0,
|
|
185
|
-
payment_amount:
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
payment_amount: 8789.68,
|
|
192
|
+
balloon_amount: 0.0,
|
|
193
|
+
total_interest: 5476.15,
|
|
194
|
+
total_paid: 105476.15
|
|
188
195
|
}
|
|
189
196
|
```
|
|
190
197
|
|
|
@@ -195,13 +202,14 @@ Returns a hash with loan totals:
|
|
|
195
202
|
| `#end_date` | Date of the last payment |
|
|
196
203
|
| `#total_interest` | Sum of all interest payments |
|
|
197
204
|
| `#total_paid` | Sum of all payments |
|
|
198
|
-
| `#payment_amount` | Regular payment amount |
|
|
205
|
+
| `#payment_amount` | Regular payment amount (excludes any balloon) |
|
|
206
|
+
| `#balloon_amount` | Balloon due with the final payment (0.0 when none) |
|
|
199
207
|
|
|
200
208
|
## Payment Frequencies
|
|
201
209
|
|
|
202
210
|
### Monthly (`:monthly`)
|
|
203
211
|
|
|
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).
|
|
212
|
+
Payments on the same day each month, starting one month after the start date (or after the grace period). If the start date is the 31st, payments on shorter months use the last day of the month, and later payments keep that earlier day (e.g., Jan 31 -> Feb 28 -> Mar 28 -> Apr 28).
|
|
205
213
|
|
|
206
214
|
### Bi-weekly (`:biweekly`)
|
|
207
215
|
|
|
@@ -228,7 +236,7 @@ The fee is added to the loan principal upfront, increasing the base amount finan
|
|
|
228
236
|
**Best for:** Rolling all costs into the loan amount
|
|
229
237
|
|
|
230
238
|
### 3. Separate Payment (`:separate_payment`)
|
|
231
|
-
The fee is collected as a separate first payment
|
|
239
|
+
The fee is collected as a separate payment (`payment_number: 0`) one period after the start. Regular payments begin one period after the fee. Interest accrues from funding, so the first regular payment carries the interest for the fee's period as well as its own, and the loan matures one period after `term_months`.
|
|
232
240
|
|
|
233
241
|
**Best for:** Collecting fees upfront separately from the loan repayment
|
|
234
242
|
|
|
@@ -240,21 +248,72 @@ Interest accrues daily on the remaining principal balance. As you pay down the p
|
|
|
240
248
|
|
|
241
249
|
**Formula:** Daily interest = (Principal Balance x Annual Rate) / 365
|
|
242
250
|
|
|
251
|
+
The level payment is sized on the actual number of days in each scheduled period (after bank-day
|
|
252
|
+
adjustments), so the final payment equals the regular payment and the schedule runs its full term.
|
|
253
|
+
|
|
254
|
+
**Negative amortization:** a level payment can be smaller than the interest on a long period, such as
|
|
255
|
+
a 31-day month at a high rate. That row shows a negative `principal_payment` and the unpaid interest is
|
|
256
|
+
added to the balance. For example, a 30-year loan at 18% does this in every 31-day month for its first
|
|
257
|
+
seven years or so (through about payment 89). With `bank_days_only: true`, weekend and holiday shifts
|
|
258
|
+
make some periods 32 to 34 days long, and it continues through about payment 193. The schedule still
|
|
259
|
+
pays off exactly on the final payment.
|
|
260
|
+
|
|
243
261
|
**Best for:** Traditional amortizing loans, consumer loans
|
|
244
262
|
|
|
245
263
|
### Precomputed Interest (`:precomputed`)
|
|
246
264
|
|
|
247
|
-
Total interest is calculated upfront
|
|
265
|
+
Total interest is calculated upfront on the principal plus origination fee for the estimated length of the loan, then divided equally across all payments. Interest per payment stays constant regardless of principal reduction.
|
|
248
266
|
|
|
249
267
|
**Best for:** Fixed payment structures, certain consumer loan types
|
|
250
268
|
|
|
269
|
+
## Balloon Payments
|
|
270
|
+
|
|
271
|
+
A balloon loan's regular payments only pay the principal down to `balloon_amount`. That remaining
|
|
272
|
+
principal is due as a lump sum with the final payment. Commercial real estate and equipment loans
|
|
273
|
+
often work this way, with a short term and payments sized as if the loan ran much longer.
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
schedule = Amortizy::AmortizationSchedule.new(
|
|
277
|
+
start_date: "2026-01-15",
|
|
278
|
+
principal: 500_000.00,
|
|
279
|
+
term_months: 60,
|
|
280
|
+
annual_rate: 7.25,
|
|
281
|
+
frequency: :monthly,
|
|
282
|
+
balloon_amount: 350_000.00
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
schedule.payment_amount # => 5103.18 (payments 1-59)
|
|
286
|
+
schedule.schedule.last
|
|
287
|
+
# => { payment_number: 60, date: #<Date 2031-01-15>, total_payment: 355103.18,
|
|
288
|
+
# payment_type: "Balloon Payment", principal_balance: 0, ... }
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
How it works:
|
|
292
|
+
|
|
293
|
+
- **Simple interest:** the payment is sized on the actual days in each period so that exactly
|
|
294
|
+
`balloon_amount` is left after the regular payments. With equal-length periods this is the standard
|
|
295
|
+
annuity payment with a future value. Interest keeps accruing on the balloon for the whole term.
|
|
296
|
+
- **Precomputed interest:** the principal portion of each payment is
|
|
297
|
+
`(amortized balance - balloon_amount) / principal payments`. Interest is unchanged.
|
|
298
|
+
- **The final payment** is the regular payment plus the balloon, to the cent.
|
|
299
|
+
- **A balloon close to the principal** leaves a payment barely above the interest, so longer periods
|
|
300
|
+
can show negative amortization (see [Simple Interest](#simple-interest-simple)). The final payment
|
|
301
|
+
still settles the loan exactly.
|
|
302
|
+
- **Works with** every frequency, `num_payments`, interest-only periods, grace periods and fees. The
|
|
303
|
+
limit is measured against the amortized balance: principal + origination fee + capitalized grace
|
|
304
|
+
interest, plus the additional fee when it is `:add_to_principal`.
|
|
305
|
+
- **Disclosures:** `Amortizy::Disclosure` reads the actual schedule, so the balloon is included in
|
|
306
|
+
the total payment amount, the finance charge and the APR. `#balloon_payment` returns the final
|
|
307
|
+
payment (regular payment plus balloon), and `#to_h` / `#to_labeled_h` include it as
|
|
308
|
+
`balloon_payment` / "Balloon Payment" when the loan has a balloon.
|
|
309
|
+
|
|
251
310
|
## Federal Bank Holidays
|
|
252
311
|
|
|
253
312
|
When `bank_days_only: true`, the schedule automatically skips weekends and US Federal Reserve holidays:
|
|
254
313
|
|
|
255
314
|
- New Year's Day, MLK Jr. Day, Presidents' Day, Memorial Day, Juneteenth, Independence Day, Labor Day, Columbus Day, Veterans Day, Thanksgiving, Christmas
|
|
256
315
|
|
|
257
|
-
Weekend observation
|
|
316
|
+
Weekend observation comes from the `holidays` gem: a Sunday holiday is observed the following Monday. **Known issue:** a Saturday holiday is currently also treated as closing the preceding Friday, which the Federal Reserve does not do ([#8](https://github.com/zapatify/amortizy/issues/8)).
|
|
258
317
|
|
|
259
318
|
## Usage Examples
|
|
260
319
|
|
|
@@ -328,12 +387,14 @@ schedule.generate(output: :csv, csv_path: "commercial_loan.csv")
|
|
|
328
387
|
|
|
329
388
|
## Commercial Financing Disclosures
|
|
330
389
|
|
|
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.).
|
|
390
|
+
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.). The calculations follow California's regulations (10 CCR §900 et seq.). Check the methodology against your state's rules before relying on it elsewhere.
|
|
332
391
|
|
|
333
392
|
The `Disclosure` class wraps an existing `AmortizationSchedule` and computes nine disclosure elements. The gem computes the numbers — your application handles document formatting.
|
|
334
393
|
|
|
335
394
|
**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
395
|
|
|
396
|
+
**Known issue:** `AmortizationSchedule` adds the origination fee to the balance the borrower repays, and `Disclosure` also deducts it from the amount financed, so the fee is counted twice. Until this is resolved, the amount financed is understated by the fee and the finance charge and APR are overstated ([#11](https://github.com/zapatify/amortizy/issues/11)). The example figures below reflect the current behavior.
|
|
397
|
+
|
|
337
398
|
### Basic Usage
|
|
338
399
|
|
|
339
400
|
```ruby
|
|
@@ -350,12 +411,12 @@ schedule = Amortizy::AmortizationSchedule.new(
|
|
|
350
411
|
|
|
351
412
|
disclosure = Amortizy::Disclosure.new(schedule)
|
|
352
413
|
|
|
353
|
-
disclosure.apr # =>
|
|
354
|
-
disclosure.finance_charge # =>
|
|
414
|
+
disclosure.apr # => 13.6 (Reg Z actuarial method, 10 bps precision)
|
|
415
|
+
disclosure.finance_charge # => 11123.13
|
|
355
416
|
disclosure.amount_financed # => 49500.0
|
|
356
|
-
disclosure.total_payment_amount # =>
|
|
417
|
+
disclosure.total_payment_amount # => 60623.13
|
|
357
418
|
disclosure.recipient_funds # => 49500.0
|
|
358
|
-
disclosure.term_display # => "3 years, 0.
|
|
419
|
+
disclosure.term_display # => "3 years, 0.03 months"
|
|
359
420
|
disclosure.average_monthly_cost # => nil (monthly frequency)
|
|
360
421
|
disclosure.prepayment # => { has_non_interest_charges: false, ... }
|
|
361
422
|
```
|
|
@@ -387,14 +448,14 @@ disclosure.prepayment
|
|
|
387
448
|
```ruby
|
|
388
449
|
# Raw values
|
|
389
450
|
disclosure.to_h
|
|
390
|
-
# => { amount_financed: 49500.0, apr:
|
|
451
|
+
# => { amount_financed: 49500.0, apr: 13.6, finance_charge: 11123.13, ... }
|
|
391
452
|
|
|
392
453
|
# With regulation-correct labels (per CA SB 1235 section 910)
|
|
393
454
|
disclosure.to_labeled_h
|
|
394
455
|
# => {
|
|
395
456
|
# amount_financed: { label: "Funding Provided", value: 49500.0 },
|
|
396
|
-
# apr: { label: "Annual Percentage Rate (APR)", value:
|
|
397
|
-
# finance_charge: { label: "Finance Charge", value:
|
|
457
|
+
# apr: { label: "Annual Percentage Rate (APR)", value: 13.6 },
|
|
458
|
+
# finance_charge: { label: "Finance Charge", value: 11123.13 },
|
|
398
459
|
# ...
|
|
399
460
|
# }
|
|
400
461
|
```
|
|
@@ -416,6 +477,7 @@ disclosure.to_labeled_h
|
|
|
416
477
|
| Finance Charge | `#finance_charge` | Total dollar cost (derived from Reg Z identity) | §943 |
|
|
417
478
|
| Total Payment Amount | `#total_payment_amount` | Sum of all borrower payments | §910(a)(5) |
|
|
418
479
|
| Payment | `#payment_amount` | Regular periodic payment | §910(a)(6) |
|
|
480
|
+
| Balloon Payment | `#balloon_payment` | Final payment of a balloon loan; `nil` (and omitted from the hashes) otherwise | - |
|
|
419
481
|
| Term | `#term_display` | Days (<=1yr) or years/months (>1yr) | §901(a)(4) |
|
|
420
482
|
| Average Monthly Cost | `#average_monthly_cost` | For non-monthly frequencies only | §910(a)(12) |
|
|
421
483
|
| Recipient Funds | `#recipient_funds` | Net amount to borrower | §900(a)(26) |
|
|
@@ -427,23 +489,10 @@ disclosure.to_labeled_h
|
|
|
427
489
|
- **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
490
|
- **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
491
|
|
|
430
|
-
## Command Line Interface
|
|
431
|
-
|
|
432
|
-
Amortizy includes an interactive CLI tool:
|
|
433
|
-
|
|
434
|
-
```bash
|
|
435
|
-
amortizy
|
|
436
|
-
```
|
|
437
|
-
|
|
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
492
|
## Requirements
|
|
444
493
|
|
|
445
|
-
- Ruby 3.
|
|
446
|
-
- `holidays` gem (~>
|
|
494
|
+
- Ruby 3.3 or higher
|
|
495
|
+
- `holidays` gem (~> 11.7) - automatically installed as a dependency
|
|
447
496
|
|
|
448
497
|
## Development
|
|
449
498
|
|
|
@@ -465,7 +514,7 @@ bin/console
|
|
|
465
514
|
|
|
466
515
|
## Testing
|
|
467
516
|
|
|
468
|
-
The
|
|
517
|
+
The RSpec suite covers:
|
|
469
518
|
|
|
470
519
|
- Initialization and validation
|
|
471
520
|
- Payment calculations for all four frequencies
|
|
@@ -474,6 +523,7 @@ The test suite includes 120 RSpec examples covering:
|
|
|
474
523
|
- Schedule generation and full amortization
|
|
475
524
|
- Interest methods (simple vs precomputed)
|
|
476
525
|
- Grace periods and interest-only periods
|
|
526
|
+
- Balloon payments across frequencies, interest methods and fees
|
|
477
527
|
- Bank day functionality and federal holiday detection
|
|
478
528
|
- Fee treatments (distributed, add to principal, separate payment)
|
|
479
529
|
- Monthly date drift prevention with bank_days_only
|
|
@@ -62,6 +62,7 @@ module Amortizy
|
|
|
62
62
|
puts "Annual Rate: #{format('%.2f', summary[:annual_rate])}%"
|
|
63
63
|
puts "\nOriginal Principal: $#{format('%.2f', summary[:principal])}"
|
|
64
64
|
puts "Regular Payment: $#{format('%.2f', summary[:payment_amount])}"
|
|
65
|
+
puts "Balloon Payment: $#{format('%.2f', summary[:balloon_amount])}" if summary[:balloon_amount].positive?
|
|
65
66
|
puts "Total Interest Paid: $#{format('%.2f', summary[:total_interest])}"
|
|
66
67
|
puts "Total Amount Paid: $#{format('%.2f', summary[:total_paid])}"
|
|
67
68
|
puts '=' * 195
|
data/lib/amortizy/disclosure.rb
CHANGED
|
@@ -70,6 +70,16 @@ module Amortizy
|
|
|
70
70
|
@schedule.payment_amount
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# --- Balloon Payment ---
|
|
74
|
+
# The final scheduled payment of a balloon loan (regular payment plus the
|
|
75
|
+
# balloon), or nil when the loan fully amortizes.
|
|
76
|
+
|
|
77
|
+
def balloon_payment
|
|
78
|
+
return nil unless @schedule.balloon_amount.positive?
|
|
79
|
+
|
|
80
|
+
@schedule.schedule.last[:total_payment]
|
|
81
|
+
end
|
|
82
|
+
|
|
73
83
|
# --- Payment Frequency ---
|
|
74
84
|
|
|
75
85
|
def payment_frequency
|
|
@@ -149,7 +159,7 @@ module Amortizy
|
|
|
149
159
|
average_monthly_cost: average_monthly_cost&.round(2),
|
|
150
160
|
recipient_funds: recipient_funds.round(2),
|
|
151
161
|
prepayment: prepayment
|
|
152
|
-
}
|
|
162
|
+
}.merge(balloon_payment ? { balloon_payment: balloon_payment.round(2) } : {})
|
|
153
163
|
end
|
|
154
164
|
|
|
155
165
|
def to_labeled_h
|
|
@@ -164,7 +174,7 @@ module Amortizy
|
|
|
164
174
|
average_monthly_cost: { label: 'Average Monthly Cost', value: average_monthly_cost&.round(2) },
|
|
165
175
|
recipient_funds: { label: 'Recipient Funds', value: recipient_funds.round(2) },
|
|
166
176
|
prepayment: prepayment
|
|
167
|
-
}
|
|
177
|
+
}.merge(balloon_payment ? { balloon_payment: { label: 'Balloon Payment', value: balloon_payment.round(2) } } : {})
|
|
168
178
|
end
|
|
169
179
|
|
|
170
180
|
private
|
data/lib/amortizy/engine.rb
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# Generates comprehensive loan amortization schedules with support for:
|
|
6
6
|
# - Grace periods with capitalized interest
|
|
7
7
|
# - Interest-only payment periods
|
|
8
|
+
# - Balloon payments due at maturity
|
|
8
9
|
# - Origination and additional fees
|
|
9
10
|
# - Simple or precomputed interest methods
|
|
10
11
|
# - Bank business day calculations (Federal Reserve holidays)
|
|
@@ -16,13 +17,15 @@ require 'holidays'
|
|
|
16
17
|
|
|
17
18
|
module Amortizy
|
|
18
19
|
class AmortizationSchedule
|
|
20
|
+
DECIMAL_STRING = /\A\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?\s*\z/
|
|
21
|
+
private_constant :DECIMAL_STRING
|
|
19
22
|
attr_reader :start_date, :principal, :term_months, :annual_rate, :frequency, :additional_fee_label,
|
|
20
|
-
:origination_fee, :additional_fee, :additional_fee_treatment
|
|
23
|
+
:origination_fee, :additional_fee, :additional_fee_treatment, :balloon_amount
|
|
21
24
|
|
|
22
25
|
def initialize(start_date:, principal:, annual_rate:, frequency:, term_months: nil, num_payments: nil,
|
|
23
26
|
origination_fee: 0, additional_fee: 0, additional_fee_label: 'Additional Fee',
|
|
24
27
|
additional_fee_treatment: :distributed, bank_days_only: false, interest_only_periods: 0,
|
|
25
|
-
grace_period_days: 0, interest_method: :simple)
|
|
28
|
+
grace_period_days: 0, interest_method: :simple, balloon_amount: 0)
|
|
26
29
|
@start_date = Date.parse(start_date.to_s)
|
|
27
30
|
@principal = principal.to_f
|
|
28
31
|
@annual_rate = annual_rate.to_f / 100.0
|
|
@@ -35,6 +38,7 @@ module Amortizy
|
|
|
35
38
|
@interest_only_periods = interest_only_periods.to_i
|
|
36
39
|
@grace_period_days = grace_period_days.to_i
|
|
37
40
|
@interest_method = interest_method.to_sym
|
|
41
|
+
@balloon_amount = parse_balloon_amount(balloon_amount)
|
|
38
42
|
|
|
39
43
|
validate_principal!
|
|
40
44
|
validate_frequency!
|
|
@@ -42,6 +46,7 @@ module Amortizy
|
|
|
42
46
|
validate_fee_treatment!
|
|
43
47
|
validate_interest_only_periods!
|
|
44
48
|
validate_interest_method!
|
|
49
|
+
validate_balloon_amount!
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
def generate(output: :console, csv_path: nil)
|
|
@@ -71,6 +76,7 @@ module Amortizy
|
|
|
71
76
|
frequency: @frequency,
|
|
72
77
|
annual_rate: @annual_rate * 100.0,
|
|
73
78
|
payment_amount: payment_amount,
|
|
79
|
+
balloon_amount: @balloon_amount,
|
|
74
80
|
total_interest: total_interest,
|
|
75
81
|
total_paid: total_paid
|
|
76
82
|
}
|
|
@@ -142,6 +148,26 @@ module Amortizy
|
|
|
142
148
|
raise ArgumentError, 'Interest method must be :simple or :precomputed'
|
|
143
149
|
end
|
|
144
150
|
|
|
151
|
+
def parse_balloon_amount(value)
|
|
152
|
+
return 0.0 if value.nil?
|
|
153
|
+
raise ArgumentError if value.is_a?(String) && !value.match?(DECIMAL_STRING)
|
|
154
|
+
|
|
155
|
+
amount = Float(value)
|
|
156
|
+
return amount if amount.finite?
|
|
157
|
+
|
|
158
|
+
raise ArgumentError
|
|
159
|
+
rescue ArgumentError, TypeError, RangeError
|
|
160
|
+
raise ArgumentError, "balloon_amount must be a number, got #{value.inspect}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_balloon_amount!
|
|
164
|
+
raise ArgumentError, 'balloon_amount must not be negative' if @balloon_amount.negative?
|
|
165
|
+
return if @balloon_amount < effective_principal
|
|
166
|
+
|
|
167
|
+
raise ArgumentError,
|
|
168
|
+
"balloon_amount must be less than the amortized balance (#{format('%.2f', effective_principal)})"
|
|
169
|
+
end
|
|
170
|
+
|
|
145
171
|
def total_payments
|
|
146
172
|
@num_payments
|
|
147
173
|
end
|
|
@@ -199,10 +225,10 @@ module Amortizy
|
|
|
199
225
|
total_days.to_f / sample_size
|
|
200
226
|
end
|
|
201
227
|
|
|
228
|
+
# A separate fee row takes a period of its own, and interest accrues through it.
|
|
202
229
|
def estimate_total_loan_days
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
num * avg_days_per_period
|
|
230
|
+
periods = total_payments + (separate_fee_payment? ? 1 : 0)
|
|
231
|
+
periods * calculate_average_days_per_period
|
|
206
232
|
end
|
|
207
233
|
|
|
208
234
|
def calculate_precomputed_total_interest
|
|
@@ -251,7 +277,7 @@ module Amortizy
|
|
|
251
277
|
principal_payments = num - @interest_only_periods
|
|
252
278
|
|
|
253
279
|
if @interest_method == :precomputed
|
|
254
|
-
principal_payment_portion = effective_principal / principal_payments
|
|
280
|
+
principal_payment_portion = (effective_principal - @balloon_amount) / principal_payments
|
|
255
281
|
interest_portion = precomputed_interest_per_payment
|
|
256
282
|
|
|
257
283
|
if @additional_fee_treatment == :distributed
|
|
@@ -260,15 +286,7 @@ module Amortizy
|
|
|
260
286
|
principal_payment_portion + interest_portion
|
|
261
287
|
end
|
|
262
288
|
else
|
|
263
|
-
|
|
264
|
-
period_rate = (@annual_rate / 365.0) * days_per_period
|
|
265
|
-
|
|
266
|
-
base_payment = if period_rate.zero?
|
|
267
|
-
effective_principal / principal_payments
|
|
268
|
-
else
|
|
269
|
-
effective_principal * (period_rate * ((1 + period_rate)**principal_payments)) /
|
|
270
|
-
(((1 + period_rate)**principal_payments) - 1)
|
|
271
|
-
end
|
|
289
|
+
base_payment = level_payment
|
|
272
290
|
|
|
273
291
|
if @additional_fee_treatment == :distributed
|
|
274
292
|
base_payment + (@additional_fee / num)
|
|
@@ -278,6 +296,67 @@ module Amortizy
|
|
|
278
296
|
end
|
|
279
297
|
end
|
|
280
298
|
|
|
299
|
+
# The level payment that leaves the balloon (zero for a fully amortizing loan)
|
|
300
|
+
# outstanding with the final payment, when interest is charged on each
|
|
301
|
+
# period's actual days. Interest-only periods leave the balance unchanged, so
|
|
302
|
+
# only the amortizing periods count. Each period grows the balance by
|
|
303
|
+
# (1 + r_k), which makes the final balance linear in the payment:
|
|
304
|
+
#
|
|
305
|
+
# payment = (balance * prod(1 + r_k) - balloon) / sum_k prod_{i>k}(1 + r_i)
|
|
306
|
+
def level_payment
|
|
307
|
+
growth = 1.0
|
|
308
|
+
payments_value = 0.0
|
|
309
|
+
amortizing_period_rates.reverse_each do |rate|
|
|
310
|
+
payments_value += growth
|
|
311
|
+
growth *= 1 + rate
|
|
312
|
+
end
|
|
313
|
+
((effective_principal * growth) - @balloon_amount) / payments_value
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Interest accrues from funding (or the end of the grace period). A separate
|
|
317
|
+
# fee row collects no interest, so the first regular payment carries the
|
|
318
|
+
# interest for the fee's period too.
|
|
319
|
+
def amortizing_period_rates
|
|
320
|
+
previous_date = first_payment_date
|
|
321
|
+
rates = regular_payment_dates.map do |date|
|
|
322
|
+
rate = (@annual_rate / 365.0) * calculate_days_between(previous_date, date)
|
|
323
|
+
previous_date = date
|
|
324
|
+
rate
|
|
325
|
+
end
|
|
326
|
+
rates.drop(@interest_only_periods)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def separate_fee_payment?
|
|
330
|
+
@additional_fee_treatment == :separate_payment && @additional_fee.positive?
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def separate_fee_date
|
|
334
|
+
next_payment_date(first_payment_date)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def first_regular_period_start
|
|
338
|
+
separate_fee_payment? ? separate_fee_date : first_payment_date
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# For monthly frequency, track the target date separately from the actual
|
|
342
|
+
# payment date to prevent bank-day adjustments from compounding drift.
|
|
343
|
+
def regular_payment_dates
|
|
344
|
+
@regular_payment_dates ||= begin
|
|
345
|
+
# A separate fee takes the first period, so regular payments start a
|
|
346
|
+
# month after the fee's (unadjusted) target date.
|
|
347
|
+
monthly_target = separate_fee_payment? ? advance_by_month(first_payment_date) : first_payment_date
|
|
348
|
+
previous_date = first_regular_period_start
|
|
349
|
+
Array.new(total_payments) do
|
|
350
|
+
if @frequency == :monthly
|
|
351
|
+
monthly_target = advance_by_month(monthly_target)
|
|
352
|
+
previous_date = next_bank_day(monthly_target)
|
|
353
|
+
else
|
|
354
|
+
previous_date = next_payment_date(previous_date)
|
|
355
|
+
end
|
|
356
|
+
end.freeze
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
281
360
|
def federal_holiday?(date)
|
|
282
361
|
holidays = Holidays.on(date, :federalreserve, :observed)
|
|
283
362
|
!holidays.empty?
|
|
@@ -330,7 +409,6 @@ module Amortizy
|
|
|
330
409
|
payment_amount = calculate_payment
|
|
331
410
|
balance = effective_principal
|
|
332
411
|
accrued_interest = 0.0
|
|
333
|
-
payment_date = first_payment_date
|
|
334
412
|
previous_payment_date = first_payment_date
|
|
335
413
|
payment_number = 0
|
|
336
414
|
num = total_payments
|
|
@@ -354,8 +432,8 @@ module Amortizy
|
|
|
354
432
|
}
|
|
355
433
|
end
|
|
356
434
|
|
|
357
|
-
if
|
|
358
|
-
payment_date =
|
|
435
|
+
if separate_fee_payment?
|
|
436
|
+
payment_date = separate_fee_date
|
|
359
437
|
schedule_data << {
|
|
360
438
|
payment_number: 0,
|
|
361
439
|
date: payment_date,
|
|
@@ -369,26 +447,15 @@ module Amortizy
|
|
|
369
447
|
payment_type: 'Additional Fee Payment',
|
|
370
448
|
days_in_period: 0
|
|
371
449
|
}
|
|
372
|
-
previous_payment_date = payment_date
|
|
373
450
|
end
|
|
374
451
|
|
|
375
452
|
additional_fee_per_payment = @additional_fee_treatment == :distributed ? (@additional_fee / num) : 0.0
|
|
376
453
|
precomputed_interest = @interest_method == :precomputed ? precomputed_interest_per_payment : 0.0
|
|
377
454
|
|
|
378
|
-
# For monthly frequency, track the target date separately from the actual
|
|
379
|
-
# payment date to prevent bank-day adjustments from compounding drift.
|
|
380
|
-
monthly_target = @frequency == :monthly ? first_payment_date : nil
|
|
381
|
-
|
|
382
455
|
while payment_number < num && balance > 0.01
|
|
456
|
+
payment_date = regular_payment_dates[payment_number]
|
|
383
457
|
payment_number += 1
|
|
384
458
|
|
|
385
|
-
if @frequency == :monthly && monthly_target
|
|
386
|
-
monthly_target = advance_by_month(monthly_target)
|
|
387
|
-
payment_date = next_bank_day(monthly_target)
|
|
388
|
-
else
|
|
389
|
-
payment_date = next_payment_date(previous_payment_date)
|
|
390
|
-
end
|
|
391
|
-
|
|
392
459
|
days_in_period = calculate_days_between(previous_payment_date, payment_date)
|
|
393
460
|
|
|
394
461
|
if @interest_method == :precomputed
|
|
@@ -407,7 +474,7 @@ module Amortizy
|
|
|
407
474
|
else
|
|
408
475
|
principal_payment = [payment_amount - interest_payment - additional_fee_per_payment, balance].min
|
|
409
476
|
principal_payment = balance if payment_number == num
|
|
410
|
-
payment_type = 'Regular Payment'
|
|
477
|
+
payment_type = payment_number == num && @balloon_amount.positive? ? 'Balloon Payment' : 'Regular Payment'
|
|
411
478
|
end
|
|
412
479
|
|
|
413
480
|
total_payment = principal_payment + interest_payment + additional_fee_per_payment
|
data/lib/amortizy/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: amortizy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rich Zapata
|
|
@@ -15,17 +15,17 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '11.7'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
25
|
+
version: '11.7'
|
|
26
26
|
description: Generate loan amortization schedules with monthly, bi-weekly, weekly,
|
|
27
|
-
or daily payments. Supports grace periods, interest-only payments,
|
|
28
|
-
holidays, and multiple interest calculation methods.
|
|
27
|
+
or daily payments. Supports grace periods, interest-only payments, balloon payments,
|
|
28
|
+
federal bank holidays, and multiple interest calculation methods.
|
|
29
29
|
email:
|
|
30
30
|
- rzapata@gmail.com
|
|
31
31
|
executables: []
|
|
@@ -44,7 +44,6 @@ files:
|
|
|
44
44
|
- lib/amortizy/engine.rb
|
|
45
45
|
- lib/amortizy/version.rb
|
|
46
46
|
- sig/amortizy.rbs
|
|
47
|
-
- tasks/todo.md
|
|
48
47
|
homepage: https://github.com/zapatify/amortizy
|
|
49
48
|
licenses:
|
|
50
49
|
- MIT
|
|
@@ -61,7 +60,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
61
60
|
requirements:
|
|
62
61
|
- - ">="
|
|
63
62
|
- !ruby/object:Gem::Version
|
|
64
|
-
version: 3.
|
|
63
|
+
version: 3.3.0
|
|
65
64
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
65
|
requirements:
|
|
67
66
|
- - ">="
|
data/tasks/todo.md
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
# Amortizy v2.1 — Commercial Financing Disclosure Module
|
|
2
|
-
|
|
3
|
-
## Goal
|
|
4
|
-
|
|
5
|
-
Add a `Disclosure` class that computes California SB 1235 disclosure data elements
|
|
6
|
-
for closed-end commercial loan transactions. The gem exposes jurisdiction-agnostic
|
|
7
|
-
numbers — APR (Reg Z actuarial method), finance charge, amount financed, recipient
|
|
8
|
-
funds, term, average monthly cost, and prepayment info — queryable via a clean API.
|
|
9
|
-
|
|
10
|
-
**Design decisions:**
|
|
11
|
-
- `Amortizy::Disclosure` is a separate class that wraps an `AmortizationSchedule`
|
|
12
|
-
- Origination fee is treated as a prepaid finance charge by default (deducted from
|
|
13
|
-
amount financed, included in finance charge) — must be documented
|
|
14
|
-
- No state/jurisdiction parameters — data elements are universal
|
|
15
|
-
- No document formatting — the gem computes numbers, not PDFs
|
|
16
|
-
- Scoped to closed-end transactions only (no MCA, factoring, leases, open-end)
|
|
17
|
-
|
|
18
|
-
**Regulatory references:**
|
|
19
|
-
- Section 910: Closed-end transaction disclosure contents
|
|
20
|
-
- Section 940: APR calculation (Appendix J, 12 CFR Part 1026 — actuarial method)
|
|
21
|
-
- Section 943: Finance charge definition
|
|
22
|
-
- Section 900(a)(1)(B): Amount financed for closed-end transactions
|
|
23
|
-
- Section 900(a)(26): Recipient funds
|
|
24
|
-
- Section 955: APR tolerance (1/8 of 1 percentage point)
|
|
25
|
-
|
|
26
|
-
---
|
|
27
|
-
|
|
28
|
-
## Phase 1: Disclosure Class Skeleton
|
|
29
|
-
|
|
30
|
-
### 1.1 Create `lib/amortizy/disclosure.rb`
|
|
31
|
-
- [ ] Define `Amortizy::Disclosure` class
|
|
32
|
-
- [ ] Accept `AmortizationSchedule` as first argument
|
|
33
|
-
- [ ] Accept keyword args: `third_party_payments: 0`, `prepayment_penalty_max: 0`,
|
|
34
|
-
`additional_prepayment_fees: []`
|
|
35
|
-
- [ ] Validate that schedule argument is an `AmortizationSchedule`
|
|
36
|
-
- [ ] Store schedule reference and disclosure-specific params
|
|
37
|
-
|
|
38
|
-
### 1.2 Wire into gem
|
|
39
|
-
- [ ] Add `require_relative 'amortizy/disclosure'` to `lib/amortizy.rb`
|
|
40
|
-
- [ ] Create `spec/disclosure_spec.rb` with basic instantiation tests
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
## Phase 2: Simple Computed Properties
|
|
45
|
-
|
|
46
|
-
These derive directly from existing schedule data with minimal new logic.
|
|
47
|
-
|
|
48
|
-
### 2.1 Finance Charge (§943)
|
|
49
|
-
- [ ] `#finance_charge` — total dollar cost of financing
|
|
50
|
-
- [ ] Calculation: `total_interest + origination_fee + additional_fee`
|
|
51
|
-
- [ ] All charges that would be included under 12 CFR 1026.4 if this were consumer credit
|
|
52
|
-
- [ ] Spec: verify against known loan (e.g., $50K, 12%, 36mo, $500 orig fee, $250 addl fee)
|
|
53
|
-
|
|
54
|
-
### 2.2 Amount Financed (§900(a)(1)(B))
|
|
55
|
-
- [ ] `#amount_financed` — principal + financed amounts - prepaid finance charges
|
|
56
|
-
- [ ] For closed-end: `principal + additional_fee (if add_to_principal) - origination_fee`
|
|
57
|
-
- [ ] Origination fee treated as prepaid finance charge (deducted)
|
|
58
|
-
- [ ] Additional fee treatment depends on `additional_fee_treatment` setting:
|
|
59
|
-
- `:distributed` or `:separate_payment` → not added to amount financed
|
|
60
|
-
- `:add_to_principal` → added to amount financed
|
|
61
|
-
- [ ] Spec: test all three fee treatment scenarios
|
|
62
|
-
|
|
63
|
-
### 2.3 Total Payment Amount (§910(a)(5))
|
|
64
|
-
- [ ] `#total_payment_amount` — delegates to `schedule.total_paid`
|
|
65
|
-
- [ ] Spec: verify matches schedule total
|
|
66
|
-
|
|
67
|
-
### 2.4 Recipient Funds (§900(a)(26))
|
|
68
|
-
- [ ] `#recipient_funds` — net amount given directly to the borrower
|
|
69
|
-
- [ ] Calculation: `amount_financed - third_party_payments`
|
|
70
|
-
- [ ] Spec: with and without third-party payments
|
|
71
|
-
|
|
72
|
-
### 2.5 Term Display (§901(a)(4))
|
|
73
|
-
- [ ] `#term_days` — total days from start to last payment
|
|
74
|
-
- [ ] `#term_display` — formatted per regulation:
|
|
75
|
-
- Term <= 1 year: display in days (e.g., "270 days")
|
|
76
|
-
- Term > 1 year: display in years and months, remainder as decimal
|
|
77
|
-
(e.g., "2 years, 3.15 months")
|
|
78
|
-
- [ ] Spec: test boundary at exactly 365 days, and multi-year terms
|
|
79
|
-
|
|
80
|
-
### 2.6 Average Monthly Cost (§910(a)(12))
|
|
81
|
-
- [ ] `#average_monthly_cost` — total payments / (term_days / 30.4)
|
|
82
|
-
- [ ] Returns `nil` for monthly frequency (only required for non-monthly)
|
|
83
|
-
- [ ] Spec: weekly and biweekly loans, verify nil for monthly
|
|
84
|
-
|
|
85
|
-
---
|
|
86
|
-
|
|
87
|
-
## Phase 3: APR Calculation (§940)
|
|
88
|
-
|
|
89
|
-
The core engineering work. APR per Appendix J of Regulation Z (12 CFR Part 1026).
|
|
90
|
-
|
|
91
|
-
### 3.1 Actuarial method solver
|
|
92
|
-
- [ ] Implement APR calculation using the actuarial method
|
|
93
|
-
- [ ] The APR is the rate `i` that satisfies:
|
|
94
|
-
`Amount Financed = Σ (Payment_k / (1 + i)^(t_k / 365))`
|
|
95
|
-
where `t_k` is the number of days from disbursement to payment k
|
|
96
|
-
- [ ] Use Newton-Raphson iteration to solve for `i`
|
|
97
|
-
- [ ] Convergence tolerance: 1/8 of 1 percentage point (0.00125) per §955
|
|
98
|
-
- [ ] Maximum iterations: 100 (with fallback to bisection if Newton diverges)
|
|
99
|
-
- [ ] Cash flows: amount financed is the initial advance (positive);
|
|
100
|
-
each scheduled payment is an outflow (negative)
|
|
101
|
-
- [ ] Include all finance charge components in the payment stream
|
|
102
|
-
|
|
103
|
-
### 3.2 Cash flow extraction
|
|
104
|
-
- [ ] Extract payment dates and amounts from the schedule
|
|
105
|
-
- [ ] Include separate_payment additional fees in the stream
|
|
106
|
-
- [ ] Include grace period payments if any
|
|
107
|
-
- [ ] Disbursement date = schedule start_date
|
|
108
|
-
- [ ] Each payment: { date:, amount: } from schedule rows
|
|
109
|
-
|
|
110
|
-
### 3.3 APR formatting
|
|
111
|
-
- [ ] `#apr` — returns APR as a percentage rounded to nearest 10 basis points (§901(a)(5))
|
|
112
|
-
e.g., 14.8, 14.85, not 14.8532
|
|
113
|
-
- [ ] Spec: verify against known Reg Z examples
|
|
114
|
-
- [ ] Spec: tolerance check — result within 1/8 percentage point of exact
|
|
115
|
-
|
|
116
|
-
### 3.4 Edge cases
|
|
117
|
-
- [ ] Zero-interest loans (APR should reflect fees only)
|
|
118
|
-
- [ ] Loans with grace periods (disbursement date vs first payment date gap)
|
|
119
|
-
- [ ] Interest-only periods followed by amortizing payments
|
|
120
|
-
- [ ] Precomputed interest method
|
|
121
|
-
- [ ] Very short terms (< 30 days)
|
|
122
|
-
- [ ] Very long terms (> 10 years)
|
|
123
|
-
|
|
124
|
-
---
|
|
125
|
-
|
|
126
|
-
## Phase 4: Prepayment Information (§910(a)(8-10))
|
|
127
|
-
|
|
128
|
-
### 4.1 Prepayment hash
|
|
129
|
-
- [ ] `#prepayment` — returns hash:
|
|
130
|
-
```ruby
|
|
131
|
-
{
|
|
132
|
-
has_non_interest_charges: true/false,
|
|
133
|
-
max_non_interest_finance_charge: Float or nil,
|
|
134
|
-
has_additional_fees: true/false,
|
|
135
|
-
additional_fees: [] # array of { amount:, description: } hashes
|
|
136
|
-
}
|
|
137
|
-
```
|
|
138
|
-
- [ ] Derived from `prepayment_penalty_max` and `additional_prepayment_fees` inputs
|
|
139
|
-
- [ ] Spec: all four combinations (charges/no charges x fees/no fees)
|
|
140
|
-
|
|
141
|
-
---
|
|
142
|
-
|
|
143
|
-
## Phase 5: Output Methods
|
|
144
|
-
|
|
145
|
-
### 5.1 `#to_h`
|
|
146
|
-
- [ ] Returns flat hash of all computed values:
|
|
147
|
-
```ruby
|
|
148
|
-
{
|
|
149
|
-
amount_financed:, apr:, finance_charge:, total_payment_amount:,
|
|
150
|
-
payment_amount:, payment_frequency:, term_days:, term_display:,
|
|
151
|
-
average_monthly_cost:, recipient_funds:, prepayment:
|
|
152
|
-
}
|
|
153
|
-
```
|
|
154
|
-
- [ ] All monetary values as Float, rounded to 2 decimal places
|
|
155
|
-
- [ ] Spec: verify all keys present, types correct
|
|
156
|
-
|
|
157
|
-
### 5.2 `#to_labeled_h`
|
|
158
|
-
- [ ] Returns hash with regulation-correct labels:
|
|
159
|
-
```ruby
|
|
160
|
-
{
|
|
161
|
-
amount_financed: { label: "Funding Provided", value: 50_250.00 },
|
|
162
|
-
apr: { label: "Annual Percentage Rate (APR)", value: 14.85 },
|
|
163
|
-
finance_charge: { label: "Finance Charge", value: 10_432.50 },
|
|
164
|
-
...
|
|
165
|
-
}
|
|
166
|
-
```
|
|
167
|
-
- [ ] Labels follow §910 column 1 text for fixed-rate closed-end transactions
|
|
168
|
-
- [ ] Spec: verify exact label strings match regulation text
|
|
169
|
-
|
|
170
|
-
---
|
|
171
|
-
|
|
172
|
-
## Phase 6: Documentation & Polish
|
|
173
|
-
|
|
174
|
-
### 6.1 README updates
|
|
175
|
-
- [ ] Add Disclosure section to README with usage examples
|
|
176
|
-
- [ ] Document the origination fee = prepaid finance charge assumption
|
|
177
|
-
- [ ] Document regulatory references (CA SB 1235, sections 910/940/943)
|
|
178
|
-
- [ ] Note that labels follow California regulation but values are jurisdiction-agnostic
|
|
179
|
-
|
|
180
|
-
### 6.2 CHANGELOG
|
|
181
|
-
- [ ] Add v2.1.0 entry documenting the disclosure module
|
|
182
|
-
|
|
183
|
-
### 6.3 Version bump
|
|
184
|
-
- [ ] Bump version to 2.1.0 in `version.rb`
|
|
185
|
-
|
|
186
|
-
### 6.4 Final spec run
|
|
187
|
-
- [ ] Full suite green — existing specs unaffected
|
|
188
|
-
- [ ] Disclosure specs comprehensive — all nine data elements tested
|
|
189
|
-
- [ ] APR accuracy verified against at least 3 known Reg Z examples
|
|
190
|
-
|
|
191
|
-
---
|
|
192
|
-
|
|
193
|
-
## Out of Scope
|
|
194
|
-
|
|
195
|
-
- MCA / sales-based financing disclosures (§914)
|
|
196
|
-
- Factoring disclosures (§912, §913)
|
|
197
|
-
- Open-end credit plan disclosures (§911)
|
|
198
|
-
- Lease financing disclosures (§915)
|
|
199
|
-
- Asset-based lending disclosures (§916)
|
|
200
|
-
- Disclosure document generation (PDF/HTML formatting)
|
|
201
|
-
- Jurisdiction detection or state-specific logic
|
|
202
|
-
- Adjustable rate APR estimation (amortizy only does fixed-rate)
|
|
203
|
-
- Retrospective APR auditing (§931)
|