amortizy 2.0.0 → 3.1.1
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 +59 -1
- data/README.md +188 -33
- data/lib/amortizy/console_formatter.rb +1 -0
- data/lib/amortizy/disclosure.rb +316 -0
- data/lib/amortizy/engine.rb +91 -27
- data/lib/amortizy/version.rb +1 -1
- data/lib/amortizy.rb +1 -0
- metadata +7 -7
- data/tasks/todo.md +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ca0ea325340402016df8375d37a1df326360b5c83757133ab91d7d90586d98b
|
|
4
|
+
data.tar.gz: d8323ccba488c8e3fd4ed75095ede9b84615011bcdd881e038b9e539ffc2d694
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfa7d476af355d10e128f26906c102f9fed268c8ac62ade499bb5cefb230d7288579d98e27970271250a7ad6e1d8c6f1fdb9d4230ab06d2ea1e0322af6706e19
|
|
7
|
+
data.tar.gz: b2c3bef7394514492942ff87f61f229f74a398337298a3bd9671a01c48ff914357fd50ba2146210b56c7907624b9667c6df491e76699239c2f180b78c0780e2c
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,65 @@ 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.1] - 2026-10-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- README example outputs now match what the gem returns (Quick Start, `#schedule`, `#summary`, disclosure APR and term)
|
|
12
|
+
- README no longer documents an `amortizy` command-line tool, which the gem has never shipped
|
|
13
|
+
- The packaged gem no longer includes internal planning notes (`tasks/`)
|
|
14
|
+
- README notes the known origination-fee double count in disclosures (#11) and the Saturday holiday issue (#8)
|
|
15
|
+
|
|
16
|
+
## [3.1.0] - 2026-10-21
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- `balloon_amount:` leaves a lump sum of principal due with the final payment (#3)
|
|
20
|
+
- The final row of a balloon loan has `payment_type: "Balloon Payment"`
|
|
21
|
+
- `#balloon_amount` reader and `balloon_amount` in `#summary`
|
|
22
|
+
- Console summary shows the balloon when there is one
|
|
23
|
+
- `Disclosure#balloon_payment`, and `balloon_payment` in `#to_h` / `#to_labeled_h` for balloon loans
|
|
24
|
+
|
|
25
|
+
## [3.0.1] - 2026-10-21
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- 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`
|
|
32
|
+
|
|
33
|
+
## [3.0.0] - 2026-10-21
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- Requires Ruby 3.3 or newer (3.0, 3.1 and 3.2 are end-of-life)
|
|
37
|
+
- `holidays` dependency updated from 8.x to 11.7; holiday dates are unchanged
|
|
38
|
+
- CI now tests Ruby 3.3, 3.4 and 4.0
|
|
39
|
+
|
|
40
|
+
## [2.1.0] - 2026-04-20
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
#### Commercial Financing Disclosure Module
|
|
45
|
+
- **`Amortizy::Disclosure`** - new class that computes disclosure data elements for
|
|
46
|
+
closed-end commercial loan transactions per California SB 1235 (Title 10, Chapter 3)
|
|
47
|
+
- **APR calculation** using the actuarial method per Appendix J of Regulation Z
|
|
48
|
+
(12 CFR Part 1026, section 940) with Newton-Raphson solver and bisection fallback
|
|
49
|
+
- **Finance charge** - total dollar cost of financing including interest and all fees (section 943)
|
|
50
|
+
- **Amount financed** - principal adjusted for prepaid finance charges (section 900(a)(1)(B))
|
|
51
|
+
- **Recipient funds** - net amount disbursed to borrower after third-party payoffs (section 900(a)(26))
|
|
52
|
+
- **Term display** - formatted per regulation: days for terms <= 1 year,
|
|
53
|
+
years/months for longer terms (section 901(a)(4))
|
|
54
|
+
- **Average monthly cost** - automatically computed for non-monthly payment frequencies (section 910(a)(12))
|
|
55
|
+
- **Prepayment information** - structured data for prepayment penalty disclosures (section 910(a)(8-10))
|
|
56
|
+
- **`#to_h`** - flat hash of all computed values
|
|
57
|
+
- **`#to_labeled_h`** - hash with regulation-correct labels from section 910
|
|
58
|
+
|
|
59
|
+
#### Design Notes
|
|
60
|
+
- Disclosure class wraps an existing `AmortizationSchedule` — no changes to the engine API
|
|
61
|
+
- Values are jurisdiction-agnostic: computed per the most stringent methodology (California),
|
|
62
|
+
usable for any state's commercial financing disclosure requirements
|
|
63
|
+
- Origination fee is treated as a prepaid finance charge by default
|
|
64
|
+
- APR rounded to nearest 10 basis points per section 901(a)(5)
|
|
65
|
+
- APR accuracy within 1/8 of 1 percentage point tolerance per section 955
|
|
66
|
+
|
|
8
67
|
## [2.0.0] - 2026-04-19
|
|
9
68
|
|
|
10
69
|
### Added
|
|
@@ -127,7 +186,6 @@ bundle update amortizy
|
|
|
127
186
|
|
|
128
187
|
Potential features for future releases:
|
|
129
188
|
|
|
130
|
-
- Additional payment frequencies (monthly, bi-weekly)
|
|
131
189
|
- More interest calculation methods
|
|
132
190
|
- Support for additional holiday calendars (state, international)
|
|
133
191
|
- Balloon payment support
|
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,11 +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
|
|
24
|
+
- **Tested**: RSpec suite covering every option above
|
|
23
25
|
|
|
24
26
|
## Installation
|
|
25
27
|
|
|
@@ -61,9 +63,9 @@ schedule.generate
|
|
|
61
63
|
|
|
62
64
|
# Access data programmatically
|
|
63
65
|
schedule.summary # => { start_date:, end_date:, principal:, total_payments:, ... }
|
|
64
|
-
schedule.payment_amount # =>
|
|
65
|
-
schedule.total_interest # =>
|
|
66
|
-
schedule.total_paid # =>
|
|
66
|
+
schedule.payment_amount # => 8789.68
|
|
67
|
+
schedule.total_interest # => 5476.15
|
|
68
|
+
schedule.total_paid # => 105476.15
|
|
67
69
|
schedule.end_date # => #<Date: 2027-01-15>
|
|
68
70
|
|
|
69
71
|
# Get the full schedule as an array of hashes
|
|
@@ -93,7 +95,7 @@ schedule.end_date # => the date of the 26th payment
|
|
|
93
95
|
schedule.term_months # => nil (not applicable when using num_payments)
|
|
94
96
|
```
|
|
95
97
|
|
|
96
|
-
### Advanced Example
|
|
98
|
+
### Advanced Example
|
|
97
99
|
|
|
98
100
|
```ruby
|
|
99
101
|
schedule = Amortizy::AmortizationSchedule.new(
|
|
@@ -135,6 +137,7 @@ schedule.generate
|
|
|
135
137
|
| `interest_only_periods` | Integer | No | 0 | - | Number of interest-only payments |
|
|
136
138
|
| `grace_period_days` | Integer | No | 0 | - | Days before first payment |
|
|
137
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) |
|
|
138
141
|
|
|
139
142
|
*Provide either `term_months` or `num_payments`, not both.
|
|
140
143
|
|
|
@@ -156,19 +159,23 @@ Returns a frozen array of payment hashes. Each hash contains:
|
|
|
156
159
|
```ruby
|
|
157
160
|
{
|
|
158
161
|
payment_number: 1,
|
|
159
|
-
date: #<Date>,
|
|
160
|
-
principal_payment:
|
|
161
|
-
interest_payment:
|
|
162
|
+
date: #<Date: 2026-02-15>,
|
|
163
|
+
principal_payment: 7940.36,
|
|
164
|
+
interest_payment: 849.32,
|
|
162
165
|
additional_fee_payment: 0.0,
|
|
163
|
-
total_payment:
|
|
164
|
-
principal_balance:
|
|
165
|
-
accrued_interest:
|
|
166
|
-
total_balance:
|
|
166
|
+
total_payment: 8789.68,
|
|
167
|
+
principal_balance: 92059.64,
|
|
168
|
+
accrued_interest: 849.32,
|
|
169
|
+
total_balance: 92908.95,
|
|
167
170
|
payment_type: "Regular Payment",
|
|
168
|
-
days_in_period:
|
|
171
|
+
days_in_period: 31
|
|
169
172
|
}
|
|
170
173
|
```
|
|
171
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
|
+
|
|
172
179
|
#### `#summary`
|
|
173
180
|
|
|
174
181
|
Returns a hash with loan totals:
|
|
@@ -181,9 +188,10 @@ Returns a hash with loan totals:
|
|
|
181
188
|
total_payments: 12,
|
|
182
189
|
frequency: :monthly,
|
|
183
190
|
annual_rate: 10.0,
|
|
184
|
-
payment_amount:
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
payment_amount: 8789.68,
|
|
192
|
+
balloon_amount: 0.0,
|
|
193
|
+
total_interest: 5476.15,
|
|
194
|
+
total_paid: 105476.15
|
|
187
195
|
}
|
|
188
196
|
```
|
|
189
197
|
|
|
@@ -194,13 +202,14 @@ Returns a hash with loan totals:
|
|
|
194
202
|
| `#end_date` | Date of the last payment |
|
|
195
203
|
| `#total_interest` | Sum of all interest payments |
|
|
196
204
|
| `#total_paid` | Sum of all payments |
|
|
197
|
-
| `#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) |
|
|
198
207
|
|
|
199
208
|
## Payment Frequencies
|
|
200
209
|
|
|
201
210
|
### Monthly (`:monthly`)
|
|
202
211
|
|
|
203
|
-
Payments on the same day each month. If the start date is the 31st, payments on shorter months use the last day of the month (e.g., Jan 31 -> Feb 28 -> Mar 28).
|
|
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).
|
|
204
213
|
|
|
205
214
|
### Bi-weekly (`:biweekly`)
|
|
206
215
|
|
|
@@ -227,7 +236,7 @@ The fee is added to the loan principal upfront, increasing the base amount finan
|
|
|
227
236
|
**Best for:** Rolling all costs into the loan amount
|
|
228
237
|
|
|
229
238
|
### 3. Separate Payment (`:separate_payment`)
|
|
230
|
-
The fee is collected as a separate
|
|
239
|
+
The fee is collected as a separate payment (`payment_number: 0`) one period after the start, before regular amortization begins.
|
|
231
240
|
|
|
232
241
|
**Best for:** Collecting fees upfront separately from the loan repayment
|
|
233
242
|
|
|
@@ -239,21 +248,72 @@ Interest accrues daily on the remaining principal balance. As you pay down the p
|
|
|
239
248
|
|
|
240
249
|
**Formula:** Daily interest = (Principal Balance x Annual Rate) / 365
|
|
241
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
|
+
|
|
242
261
|
**Best for:** Traditional amortizing loans, consumer loans
|
|
243
262
|
|
|
244
263
|
### Precomputed Interest (`:precomputed`)
|
|
245
264
|
|
|
246
|
-
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.
|
|
247
266
|
|
|
248
267
|
**Best for:** Fixed payment structures, certain consumer loan types
|
|
249
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
|
+
|
|
250
310
|
## Federal Bank Holidays
|
|
251
311
|
|
|
252
312
|
When `bank_days_only: true`, the schedule automatically skips weekends and US Federal Reserve holidays:
|
|
253
313
|
|
|
254
314
|
- New Year's Day, MLK Jr. Day, Presidents' Day, Memorial Day, Juneteenth, Independence Day, Labor Day, Columbus Day, Veterans Day, Thanksgiving, Christmas
|
|
255
315
|
|
|
256
|
-
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)).
|
|
257
317
|
|
|
258
318
|
## Usage Examples
|
|
259
319
|
|
|
@@ -325,23 +385,114 @@ schedule = Amortizy::AmortizationSchedule.new(
|
|
|
325
385
|
schedule.generate(output: :csv, csv_path: "commercial_loan.csv")
|
|
326
386
|
```
|
|
327
387
|
|
|
328
|
-
##
|
|
388
|
+
## Commercial Financing Disclosures
|
|
329
389
|
|
|
330
|
-
Amortizy includes
|
|
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.
|
|
331
391
|
|
|
332
|
-
|
|
333
|
-
|
|
392
|
+
The `Disclosure` class wraps an existing `AmortizationSchedule` and computes nine disclosure elements. The gem computes the numbers — your application handles document formatting.
|
|
393
|
+
|
|
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.
|
|
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
|
+
|
|
398
|
+
### Basic Usage
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
schedule = Amortizy::AmortizationSchedule.new(
|
|
402
|
+
start_date: "2026-01-15",
|
|
403
|
+
principal: 50_000,
|
|
404
|
+
annual_rate: 12.0,
|
|
405
|
+
frequency: :monthly,
|
|
406
|
+
term_months: 36,
|
|
407
|
+
origination_fee: 500,
|
|
408
|
+
additional_fee: 250,
|
|
409
|
+
additional_fee_treatment: :distributed
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
disclosure = Amortizy::Disclosure.new(schedule)
|
|
413
|
+
|
|
414
|
+
disclosure.apr # => 13.6 (Reg Z actuarial method, 10 bps precision)
|
|
415
|
+
disclosure.finance_charge # => 11123.13
|
|
416
|
+
disclosure.amount_financed # => 49500.0
|
|
417
|
+
disclosure.total_payment_amount # => 60623.13
|
|
418
|
+
disclosure.recipient_funds # => 49500.0
|
|
419
|
+
disclosure.term_display # => "3 years, 0.03 months"
|
|
420
|
+
disclosure.average_monthly_cost # => nil (monthly frequency)
|
|
421
|
+
disclosure.prepayment # => { has_non_interest_charges: false, ... }
|
|
334
422
|
```
|
|
335
423
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
424
|
+
### With Third-Party Payments and Prepayment Info
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
disclosure = Amortizy::Disclosure.new(
|
|
428
|
+
schedule,
|
|
429
|
+
third_party_payments: 10_000,
|
|
430
|
+
prepayment_penalty_max: 1_200,
|
|
431
|
+
additional_prepayment_fees: [
|
|
432
|
+
{ amount: 250.0, description: "Early termination fee" }
|
|
433
|
+
]
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
disclosure.recipient_funds # => 39500.0 (amount_financed - third_party_payments)
|
|
437
|
+
disclosure.prepayment
|
|
438
|
+
# => {
|
|
439
|
+
# has_non_interest_charges: true,
|
|
440
|
+
# max_non_interest_finance_charge: 1200.0,
|
|
441
|
+
# has_additional_fees: true,
|
|
442
|
+
# additional_fees: [{ amount: 250.0, description: "Early termination fee" }]
|
|
443
|
+
# }
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Output Formats
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
# Raw values
|
|
450
|
+
disclosure.to_h
|
|
451
|
+
# => { amount_financed: 49500.0, apr: 13.6, finance_charge: 11123.13, ... }
|
|
452
|
+
|
|
453
|
+
# With regulation-correct labels (per CA SB 1235 section 910)
|
|
454
|
+
disclosure.to_labeled_h
|
|
455
|
+
# => {
|
|
456
|
+
# amount_financed: { label: "Funding Provided", value: 49500.0 },
|
|
457
|
+
# apr: { label: "Annual Percentage Rate (APR)", value: 13.6 },
|
|
458
|
+
# finance_charge: { label: "Finance Charge", value: 11123.13 },
|
|
459
|
+
# ...
|
|
460
|
+
# }
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Disclosure Parameters
|
|
464
|
+
|
|
465
|
+
| Parameter | Type | Default | Description |
|
|
466
|
+
|-----------|------|---------|-------------|
|
|
467
|
+
| `third_party_payments` | Float | 0 | Amounts paid to third parties from the financing |
|
|
468
|
+
| `prepayment_penalty_max` | Float | 0 | Maximum non-interest charge on early payoff |
|
|
469
|
+
| `additional_prepayment_fees` | Array | [] | Array of `{ amount:, description: }` hashes |
|
|
470
|
+
|
|
471
|
+
### Disclosure Elements
|
|
472
|
+
|
|
473
|
+
| Element | Method | Description | Regulatory Reference |
|
|
474
|
+
|---------|--------|-------------|---------------------|
|
|
475
|
+
| Funding Provided | `#amount_financed` | Principal adjusted for prepaid finance charges | §900(a)(1)(B) |
|
|
476
|
+
| APR | `#apr` | Annual percentage rate (Reg Z actuarial method) | §940 |
|
|
477
|
+
| Finance Charge | `#finance_charge` | Total dollar cost (derived from Reg Z identity) | §943 |
|
|
478
|
+
| Total Payment Amount | `#total_payment_amount` | Sum of all borrower payments | §910(a)(5) |
|
|
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 | - |
|
|
481
|
+
| Term | `#term_display` | Days (<=1yr) or years/months (>1yr) | §901(a)(4) |
|
|
482
|
+
| Average Monthly Cost | `#average_monthly_cost` | For non-monthly frequencies only | §910(a)(12) |
|
|
483
|
+
| Recipient Funds | `#recipient_funds` | Net amount to borrower | §900(a)(26) |
|
|
484
|
+
| Prepayment | `#prepayment` | Prepayment penalty details | §910(a)(8-10) |
|
|
485
|
+
|
|
486
|
+
### Technical Notes
|
|
487
|
+
|
|
488
|
+
- **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.
|
|
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.
|
|
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.
|
|
340
491
|
|
|
341
492
|
## Requirements
|
|
342
493
|
|
|
343
|
-
- Ruby 3.
|
|
344
|
-
- `holidays` gem (~>
|
|
494
|
+
- Ruby 3.3 or higher
|
|
495
|
+
- `holidays` gem (~> 11.7) - automatically installed as a dependency
|
|
345
496
|
|
|
346
497
|
## Development
|
|
347
498
|
|
|
@@ -363,7 +514,7 @@ bin/console
|
|
|
363
514
|
|
|
364
515
|
## Testing
|
|
365
516
|
|
|
366
|
-
The
|
|
517
|
+
The RSpec suite covers:
|
|
367
518
|
|
|
368
519
|
- Initialization and validation
|
|
369
520
|
- Payment calculations for all four frequencies
|
|
@@ -372,6 +523,7 @@ The test suite includes 70 RSpec examples covering:
|
|
|
372
523
|
- Schedule generation and full amortization
|
|
373
524
|
- Interest methods (simple vs precomputed)
|
|
374
525
|
- Grace periods and interest-only periods
|
|
526
|
+
- Balloon payments across frequencies, interest methods and fees
|
|
375
527
|
- Bank day functionality and federal holiday detection
|
|
376
528
|
- Fee treatments (distributed, add to principal, separate payment)
|
|
377
529
|
- Monthly date drift prevention with bank_days_only
|
|
@@ -379,6 +531,9 @@ The test suite includes 70 RSpec examples covering:
|
|
|
379
531
|
- Public API (schedule, summary, convenience methods)
|
|
380
532
|
- Deep freeze immutability
|
|
381
533
|
- Edge cases (year boundaries, month-end clamping, input validation)
|
|
534
|
+
- Commercial financing disclosures (APR, finance charge, amount financed, all nine elements)
|
|
535
|
+
- Reg Z identity verification across all fee treatment modes
|
|
536
|
+
- APR solver convergence (grace periods, interest-only, short-term, zero-rate loans)
|
|
382
537
|
|
|
383
538
|
Run the test suite:
|
|
384
539
|
|
|
@@ -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
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Commercial Financing Disclosure Calculator
|
|
4
|
+
#
|
|
5
|
+
# Computes disclosure data elements for closed-end commercial loan transactions
|
|
6
|
+
# per California SB 1235 (Title 10, Chapter 3, Subchapter 3). Values are
|
|
7
|
+
# jurisdiction-agnostic — the gem computes numbers, not documents.
|
|
8
|
+
#
|
|
9
|
+
# APR is calculated using the actuarial method per Appendix J of Regulation Z
|
|
10
|
+
# (12 CFR Part 1026), as required by section 940 of the regulation.
|
|
11
|
+
#
|
|
12
|
+
# Key assumption: the origination fee is treated as a prepaid finance charge
|
|
13
|
+
# (deducted from amount financed, included in finance charge).
|
|
14
|
+
#
|
|
15
|
+
# Regulatory references:
|
|
16
|
+
# - Section 910: Closed-end transaction disclosure contents
|
|
17
|
+
# - Section 940: APR calculation method
|
|
18
|
+
# - Section 943: Finance charge definition
|
|
19
|
+
# - Section 900(a)(1)(B): Amount financed for closed-end transactions
|
|
20
|
+
# - Section 900(a)(26): Recipient funds
|
|
21
|
+
# - Section 901(a)(4): Term display formatting
|
|
22
|
+
# - Section 901(a)(5): APR rounding (nearest 10 basis points)
|
|
23
|
+
# - Section 955: APR tolerance (1/8 of 1 percentage point)
|
|
24
|
+
|
|
25
|
+
module Amortizy
|
|
26
|
+
class Disclosure
|
|
27
|
+
APR_MAX_ITERATIONS = 100
|
|
28
|
+
APR_CONVERGENCE_TOLERANCE = 1e-8
|
|
29
|
+
APR_REG_Z_TOLERANCE = 0.00125 # 1/8 of 1 percentage point
|
|
30
|
+
|
|
31
|
+
def initialize(schedule, third_party_payments: 0, prepayment_penalty_max: 0,
|
|
32
|
+
additional_prepayment_fees: [])
|
|
33
|
+
raise ArgumentError, 'First argument must be an Amortizy::AmortizationSchedule' unless schedule.is_a?(Amortizy::AmortizationSchedule)
|
|
34
|
+
|
|
35
|
+
@schedule = schedule
|
|
36
|
+
@third_party_payments = third_party_payments.to_f
|
|
37
|
+
@prepayment_penalty_max = prepayment_penalty_max.to_f
|
|
38
|
+
@additional_prepayment_fees = additional_prepayment_fees
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# --- Finance Charge (§943) ---
|
|
42
|
+
# Total dollar cost of the financing. Derived from the Reg Z identity:
|
|
43
|
+
# Finance Charge = Total Payment Amount - Amount Financed
|
|
44
|
+
# This ensures the three core disclosure values are always consistent.
|
|
45
|
+
|
|
46
|
+
def finance_charge
|
|
47
|
+
@finance_charge ||= total_payment_amount - amount_financed
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Amount Financed (§900(a)(1)(B)) ---
|
|
51
|
+
# Principal + financed amounts - prepaid finance charges (origination fee).
|
|
52
|
+
|
|
53
|
+
def amount_financed
|
|
54
|
+
@amount_financed ||= begin
|
|
55
|
+
base = principal - origination_fee
|
|
56
|
+
base += additional_fee if fee_treatment == :add_to_principal
|
|
57
|
+
base
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# --- Total Payment Amount (§910(a)(5)) ---
|
|
62
|
+
|
|
63
|
+
def total_payment_amount
|
|
64
|
+
@schedule.total_paid
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# --- Payment Amount ---
|
|
68
|
+
|
|
69
|
+
def payment_amount
|
|
70
|
+
@schedule.payment_amount
|
|
71
|
+
end
|
|
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
|
+
|
|
83
|
+
# --- Payment Frequency ---
|
|
84
|
+
|
|
85
|
+
def payment_frequency
|
|
86
|
+
@schedule.frequency
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- Recipient Funds (§900(a)(26)) ---
|
|
90
|
+
# Net amount given directly to the borrower.
|
|
91
|
+
|
|
92
|
+
def recipient_funds
|
|
93
|
+
amount_financed - @third_party_payments
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- Term (§901(a)(4)) ---
|
|
97
|
+
|
|
98
|
+
def term_days
|
|
99
|
+
@term_days ||= (@schedule.end_date - disbursement_date).to_i
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def term_display
|
|
103
|
+
if term_days <= 365
|
|
104
|
+
"#{term_days} days"
|
|
105
|
+
else
|
|
106
|
+
years = term_days / 365
|
|
107
|
+
remaining_days = term_days % 365
|
|
108
|
+
months = (remaining_days / 30.4).round(2)
|
|
109
|
+
year_label = years == 1 ? 'year' : 'years'
|
|
110
|
+
month_label = (months - 1.0).abs < 0.005 ? 'month' : 'months'
|
|
111
|
+
format('%d %s, %.2f %s', years, year_label, months, month_label)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# --- Average Monthly Cost (§910(a)(12)) ---
|
|
116
|
+
# Only for non-monthly payment frequencies.
|
|
117
|
+
|
|
118
|
+
def average_monthly_cost
|
|
119
|
+
return nil if @schedule.frequency == :monthly
|
|
120
|
+
return nil if term_days <= 0
|
|
121
|
+
|
|
122
|
+
total_payment_amount / (term_days / 30.4)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# --- APR (§940) ---
|
|
126
|
+
# Actuarial method per Appendix J, Regulation Z (12 CFR Part 1026).
|
|
127
|
+
# Rounded to nearest 10 basis points per §901(a)(5).
|
|
128
|
+
|
|
129
|
+
def apr
|
|
130
|
+
@apr ||= begin
|
|
131
|
+
raw_apr = calculate_apr
|
|
132
|
+
round_to_ten_basis_points(raw_apr)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Prepayment (§910(a)(8-10)) ---
|
|
137
|
+
|
|
138
|
+
def prepayment
|
|
139
|
+
@prepayment ||= {
|
|
140
|
+
has_non_interest_charges: @prepayment_penalty_max.positive?,
|
|
141
|
+
max_non_interest_finance_charge: @prepayment_penalty_max.positive? ? @prepayment_penalty_max : nil,
|
|
142
|
+
has_additional_fees: @additional_prepayment_fees.any?,
|
|
143
|
+
additional_fees: @additional_prepayment_fees
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- Output Methods ---
|
|
148
|
+
|
|
149
|
+
def to_h
|
|
150
|
+
{
|
|
151
|
+
amount_financed: amount_financed.round(2),
|
|
152
|
+
apr: apr,
|
|
153
|
+
finance_charge: finance_charge.round(2),
|
|
154
|
+
total_payment_amount: total_payment_amount.round(2),
|
|
155
|
+
payment_amount: payment_amount.round(2),
|
|
156
|
+
payment_frequency: payment_frequency,
|
|
157
|
+
term_days: term_days,
|
|
158
|
+
term_display: term_display,
|
|
159
|
+
average_monthly_cost: average_monthly_cost&.round(2),
|
|
160
|
+
recipient_funds: recipient_funds.round(2),
|
|
161
|
+
prepayment: prepayment
|
|
162
|
+
}.merge(balloon_payment ? { balloon_payment: balloon_payment.round(2) } : {})
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def to_labeled_h
|
|
166
|
+
{
|
|
167
|
+
amount_financed: { label: 'Funding Provided', value: amount_financed.round(2) },
|
|
168
|
+
apr: { label: 'Annual Percentage Rate (APR)', value: apr },
|
|
169
|
+
finance_charge: { label: 'Finance Charge', value: finance_charge.round(2) },
|
|
170
|
+
total_payment_amount: { label: 'Total Payment Amount', value: total_payment_amount.round(2) },
|
|
171
|
+
payment_amount: { label: 'Payment', value: payment_amount.round(2) },
|
|
172
|
+
payment_frequency: { label: 'Payment Frequency', value: payment_frequency },
|
|
173
|
+
term_days: { label: 'Term', value: term_display },
|
|
174
|
+
average_monthly_cost: { label: 'Average Monthly Cost', value: average_monthly_cost&.round(2) },
|
|
175
|
+
recipient_funds: { label: 'Recipient Funds', value: recipient_funds.round(2) },
|
|
176
|
+
prepayment: prepayment
|
|
177
|
+
}.merge(balloon_payment ? { balloon_payment: { label: 'Balloon Payment', value: balloon_payment.round(2) } } : {})
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def disbursement_date
|
|
183
|
+
@schedule.start_date
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def principal
|
|
187
|
+
@schedule.principal
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def origination_fee
|
|
191
|
+
@schedule.origination_fee
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def additional_fee
|
|
195
|
+
@schedule.additional_fee
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def fee_treatment
|
|
199
|
+
@schedule.additional_fee_treatment
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# --- APR Calculation ---
|
|
203
|
+
#
|
|
204
|
+
# Solves for the annual rate i such that:
|
|
205
|
+
# Amount Financed = Σ (Payment_k / (1 + i/365)^(days_k))
|
|
206
|
+
#
|
|
207
|
+
# where days_k is the number of days from disbursement to payment k.
|
|
208
|
+
#
|
|
209
|
+
# Uses Newton-Raphson with bisection fallback.
|
|
210
|
+
|
|
211
|
+
def calculate_apr
|
|
212
|
+
cash_flows = extract_cash_flows
|
|
213
|
+
return 0.0 if cash_flows.empty?
|
|
214
|
+
return 0.0 if amount_financed <= 0
|
|
215
|
+
return 0.0 if cash_flows.length > 10_000
|
|
216
|
+
|
|
217
|
+
# Initial guess: use the nominal annual rate as starting point
|
|
218
|
+
guess = @schedule.annual_rate
|
|
219
|
+
guess = 0.10 if guess.zero?
|
|
220
|
+
|
|
221
|
+
apr_newton_raphson(cash_flows, guess) || apr_bisection(cash_flows)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def extract_cash_flows
|
|
225
|
+
flows = []
|
|
226
|
+
start = disbursement_date
|
|
227
|
+
|
|
228
|
+
@schedule.schedule.each do |row|
|
|
229
|
+
payment = row[:total_payment]
|
|
230
|
+
next if payment.nil? || payment <= 0
|
|
231
|
+
|
|
232
|
+
days_from_start = (row[:date] - start).to_i
|
|
233
|
+
next if days_from_start <= 0
|
|
234
|
+
|
|
235
|
+
flows << { days: days_from_start, amount: payment }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
flows
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Present value of all payments at annual rate r, minus amount financed.
|
|
242
|
+
# When this equals zero, r is the APR.
|
|
243
|
+
def pv_function(cash_flows, annual_rate)
|
|
244
|
+
daily_rate = annual_rate / 365.0
|
|
245
|
+
pv = cash_flows.sum do |cf|
|
|
246
|
+
cf[:amount] / ((1.0 + daily_rate)**cf[:days])
|
|
247
|
+
end
|
|
248
|
+
pv - amount_financed
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Derivative of the PV function with respect to annual_rate.
|
|
252
|
+
def pv_derivative(cash_flows, annual_rate)
|
|
253
|
+
daily_rate = annual_rate / 365.0
|
|
254
|
+
cash_flows.sum do |cf|
|
|
255
|
+
-cf[:days] * cf[:amount] / (365.0 * ((1.0 + daily_rate)**(cf[:days] + 1)))
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def apr_newton_raphson(cash_flows, initial_guess)
|
|
260
|
+
rate = initial_guess
|
|
261
|
+
|
|
262
|
+
APR_MAX_ITERATIONS.times do
|
|
263
|
+
f = pv_function(cash_flows, rate)
|
|
264
|
+
f_prime = pv_derivative(cash_flows, rate)
|
|
265
|
+
|
|
266
|
+
return rate * 100.0 if f.abs < APR_CONVERGENCE_TOLERANCE
|
|
267
|
+
return nil if f_prime.abs < 1e-15 # derivative too small, switch to bisection
|
|
268
|
+
|
|
269
|
+
step = f / f_prime
|
|
270
|
+
rate -= step
|
|
271
|
+
|
|
272
|
+
# Guard against negative rates or runaway
|
|
273
|
+
return nil if rate <= -1.0 / 365.0
|
|
274
|
+
return nil if rate > 10.0 # 1000% APR cap — something went wrong
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
nil # didn't converge
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def apr_bisection(cash_flows)
|
|
281
|
+
low = -0.001
|
|
282
|
+
high = 10.0
|
|
283
|
+
|
|
284
|
+
# Ensure the interval brackets the root
|
|
285
|
+
f_low = pv_function(cash_flows, low)
|
|
286
|
+
f_high = pv_function(cash_flows, high)
|
|
287
|
+
|
|
288
|
+
# If both same sign, try expanding
|
|
289
|
+
if (f_low * f_high).positive?
|
|
290
|
+
high = 50.0
|
|
291
|
+
f_high = pv_function(cash_flows, high)
|
|
292
|
+
return 0.0 if (f_low * f_high).positive?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
(APR_MAX_ITERATIONS * 5).times do
|
|
296
|
+
mid = (low + high) / 2.0
|
|
297
|
+
f_mid = pv_function(cash_flows, mid)
|
|
298
|
+
|
|
299
|
+
return mid * 100.0 if f_mid.abs < APR_CONVERGENCE_TOLERANCE
|
|
300
|
+
|
|
301
|
+
if (f_low * f_mid).negative?
|
|
302
|
+
high = mid
|
|
303
|
+
else
|
|
304
|
+
low = mid
|
|
305
|
+
f_low = f_mid
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
((low + high) / 2.0) * 100.0
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def round_to_ten_basis_points(apr_percent)
|
|
313
|
+
(apr_percent * 10).round / 10.0
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
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,12 +17,15 @@ require 'holidays'
|
|
|
16
17
|
|
|
17
18
|
module Amortizy
|
|
18
19
|
class AmortizationSchedule
|
|
19
|
-
|
|
20
|
+
DECIMAL_STRING = /\A\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?\s*\z/
|
|
21
|
+
private_constant :DECIMAL_STRING
|
|
22
|
+
attr_reader :start_date, :principal, :term_months, :annual_rate, :frequency, :additional_fee_label,
|
|
23
|
+
:origination_fee, :additional_fee, :additional_fee_treatment, :balloon_amount
|
|
20
24
|
|
|
21
25
|
def initialize(start_date:, principal:, annual_rate:, frequency:, term_months: nil, num_payments: nil,
|
|
22
26
|
origination_fee: 0, additional_fee: 0, additional_fee_label: 'Additional Fee',
|
|
23
27
|
additional_fee_treatment: :distributed, bank_days_only: false, interest_only_periods: 0,
|
|
24
|
-
grace_period_days: 0, interest_method: :simple)
|
|
28
|
+
grace_period_days: 0, interest_method: :simple, balloon_amount: 0)
|
|
25
29
|
@start_date = Date.parse(start_date.to_s)
|
|
26
30
|
@principal = principal.to_f
|
|
27
31
|
@annual_rate = annual_rate.to_f / 100.0
|
|
@@ -34,6 +38,7 @@ module Amortizy
|
|
|
34
38
|
@interest_only_periods = interest_only_periods.to_i
|
|
35
39
|
@grace_period_days = grace_period_days.to_i
|
|
36
40
|
@interest_method = interest_method.to_sym
|
|
41
|
+
@balloon_amount = parse_balloon_amount(balloon_amount)
|
|
37
42
|
|
|
38
43
|
validate_principal!
|
|
39
44
|
validate_frequency!
|
|
@@ -41,6 +46,7 @@ module Amortizy
|
|
|
41
46
|
validate_fee_treatment!
|
|
42
47
|
validate_interest_only_periods!
|
|
43
48
|
validate_interest_method!
|
|
49
|
+
validate_balloon_amount!
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
def generate(output: :console, csv_path: nil)
|
|
@@ -70,6 +76,7 @@ module Amortizy
|
|
|
70
76
|
frequency: @frequency,
|
|
71
77
|
annual_rate: @annual_rate * 100.0,
|
|
72
78
|
payment_amount: payment_amount,
|
|
79
|
+
balloon_amount: @balloon_amount,
|
|
73
80
|
total_interest: total_interest,
|
|
74
81
|
total_paid: total_paid
|
|
75
82
|
}
|
|
@@ -141,6 +148,26 @@ module Amortizy
|
|
|
141
148
|
raise ArgumentError, 'Interest method must be :simple or :precomputed'
|
|
142
149
|
end
|
|
143
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
|
+
|
|
144
171
|
def total_payments
|
|
145
172
|
@num_payments
|
|
146
173
|
end
|
|
@@ -250,7 +277,7 @@ module Amortizy
|
|
|
250
277
|
principal_payments = num - @interest_only_periods
|
|
251
278
|
|
|
252
279
|
if @interest_method == :precomputed
|
|
253
|
-
principal_payment_portion = effective_principal / principal_payments
|
|
280
|
+
principal_payment_portion = (effective_principal - @balloon_amount) / principal_payments
|
|
254
281
|
interest_portion = precomputed_interest_per_payment
|
|
255
282
|
|
|
256
283
|
if @additional_fee_treatment == :distributed
|
|
@@ -259,15 +286,7 @@ module Amortizy
|
|
|
259
286
|
principal_payment_portion + interest_portion
|
|
260
287
|
end
|
|
261
288
|
else
|
|
262
|
-
|
|
263
|
-
period_rate = (@annual_rate / 365.0) * days_per_period
|
|
264
|
-
|
|
265
|
-
base_payment = if period_rate.zero?
|
|
266
|
-
effective_principal / principal_payments
|
|
267
|
-
else
|
|
268
|
-
effective_principal * (period_rate * ((1 + period_rate)**principal_payments)) /
|
|
269
|
-
(((1 + period_rate)**principal_payments) - 1)
|
|
270
|
-
end
|
|
289
|
+
base_payment = level_payment
|
|
271
290
|
|
|
272
291
|
if @additional_fee_treatment == :distributed
|
|
273
292
|
base_payment + (@additional_fee / num)
|
|
@@ -277,6 +296,62 @@ module Amortizy
|
|
|
277
296
|
end
|
|
278
297
|
end
|
|
279
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
|
+
def amortizing_period_rates
|
|
317
|
+
previous_date = first_regular_period_start
|
|
318
|
+
rates = regular_payment_dates.map do |date|
|
|
319
|
+
rate = (@annual_rate / 365.0) * calculate_days_between(previous_date, date)
|
|
320
|
+
previous_date = date
|
|
321
|
+
rate
|
|
322
|
+
end
|
|
323
|
+
rates.drop(@interest_only_periods)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def separate_fee_payment?
|
|
327
|
+
@additional_fee_treatment == :separate_payment && @additional_fee.positive?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def separate_fee_date
|
|
331
|
+
next_payment_date(first_payment_date)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def first_regular_period_start
|
|
335
|
+
separate_fee_payment? ? separate_fee_date : first_payment_date
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# For monthly frequency, track the target date separately from the actual
|
|
339
|
+
# payment date to prevent bank-day adjustments from compounding drift.
|
|
340
|
+
def regular_payment_dates
|
|
341
|
+
@regular_payment_dates ||= begin
|
|
342
|
+
monthly_target = first_payment_date
|
|
343
|
+
previous_date = first_regular_period_start
|
|
344
|
+
Array.new(total_payments) do
|
|
345
|
+
if @frequency == :monthly
|
|
346
|
+
monthly_target = advance_by_month(monthly_target)
|
|
347
|
+
previous_date = next_bank_day(monthly_target)
|
|
348
|
+
else
|
|
349
|
+
previous_date = next_payment_date(previous_date)
|
|
350
|
+
end
|
|
351
|
+
end.freeze
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
280
355
|
def federal_holiday?(date)
|
|
281
356
|
holidays = Holidays.on(date, :federalreserve, :observed)
|
|
282
357
|
!holidays.empty?
|
|
@@ -329,7 +404,6 @@ module Amortizy
|
|
|
329
404
|
payment_amount = calculate_payment
|
|
330
405
|
balance = effective_principal
|
|
331
406
|
accrued_interest = 0.0
|
|
332
|
-
payment_date = first_payment_date
|
|
333
407
|
previous_payment_date = first_payment_date
|
|
334
408
|
payment_number = 0
|
|
335
409
|
num = total_payments
|
|
@@ -353,8 +427,8 @@ module Amortizy
|
|
|
353
427
|
}
|
|
354
428
|
end
|
|
355
429
|
|
|
356
|
-
if
|
|
357
|
-
payment_date =
|
|
430
|
+
if separate_fee_payment?
|
|
431
|
+
payment_date = separate_fee_date
|
|
358
432
|
schedule_data << {
|
|
359
433
|
payment_number: 0,
|
|
360
434
|
date: payment_date,
|
|
@@ -374,20 +448,10 @@ module Amortizy
|
|
|
374
448
|
additional_fee_per_payment = @additional_fee_treatment == :distributed ? (@additional_fee / num) : 0.0
|
|
375
449
|
precomputed_interest = @interest_method == :precomputed ? precomputed_interest_per_payment : 0.0
|
|
376
450
|
|
|
377
|
-
# For monthly frequency, track the target date separately from the actual
|
|
378
|
-
# payment date to prevent bank-day adjustments from compounding drift.
|
|
379
|
-
monthly_target = @frequency == :monthly ? first_payment_date : nil
|
|
380
|
-
|
|
381
451
|
while payment_number < num && balance > 0.01
|
|
452
|
+
payment_date = regular_payment_dates[payment_number]
|
|
382
453
|
payment_number += 1
|
|
383
454
|
|
|
384
|
-
if @frequency == :monthly && monthly_target
|
|
385
|
-
monthly_target = advance_by_month(monthly_target)
|
|
386
|
-
payment_date = next_bank_day(monthly_target)
|
|
387
|
-
else
|
|
388
|
-
payment_date = next_payment_date(previous_payment_date)
|
|
389
|
-
end
|
|
390
|
-
|
|
391
455
|
days_in_period = calculate_days_between(previous_payment_date, payment_date)
|
|
392
456
|
|
|
393
457
|
if @interest_method == :precomputed
|
|
@@ -406,7 +470,7 @@ module Amortizy
|
|
|
406
470
|
else
|
|
407
471
|
principal_payment = [payment_amount - interest_payment - additional_fee_per_payment, balance].min
|
|
408
472
|
principal_payment = balance if payment_number == num
|
|
409
|
-
payment_type = 'Regular Payment'
|
|
473
|
+
payment_type = payment_number == num && @balloon_amount.positive? ? 'Balloon Payment' : 'Regular Payment'
|
|
410
474
|
end
|
|
411
475
|
|
|
412
476
|
total_payment = principal_payment + interest_payment + additional_fee_per_payment
|
data/lib/amortizy/version.rb
CHANGED
data/lib/amortizy.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.1
|
|
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: []
|
|
@@ -40,10 +40,10 @@ files:
|
|
|
40
40
|
- lib/amortizy.rb
|
|
41
41
|
- lib/amortizy/console_formatter.rb
|
|
42
42
|
- lib/amortizy/csv_formatter.rb
|
|
43
|
+
- lib/amortizy/disclosure.rb
|
|
43
44
|
- lib/amortizy/engine.rb
|
|
44
45
|
- lib/amortizy/version.rb
|
|
45
46
|
- sig/amortizy.rbs
|
|
46
|
-
- tasks/todo.md
|
|
47
47
|
homepage: https://github.com/zapatify/amortizy
|
|
48
48
|
licenses:
|
|
49
49
|
- MIT
|
|
@@ -60,7 +60,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
60
60
|
requirements:
|
|
61
61
|
- - ">="
|
|
62
62
|
- !ruby/object:Gem::Version
|
|
63
|
-
version: 3.
|
|
63
|
+
version: 3.3.0
|
|
64
64
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - ">="
|
data/tasks/todo.md
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# Amortizy v2.0 — Dynamic Payment Model
|
|
2
|
-
|
|
3
|
-
## Goal
|
|
4
|
-
|
|
5
|
-
Replace the hardcoded term/payment lookup table with a dynamic calculation engine.
|
|
6
|
-
Support two entry points: `term_months` (calculate payment count) or `num_payments`
|
|
7
|
-
(calculate end date). Add monthly and bi-weekly frequencies.
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Phase 1: Dynamic Payment Engine
|
|
12
|
-
|
|
13
|
-
### 1.1 Dual input model
|
|
14
|
-
- [ ] Accept `term_months` OR `num_payments` — one required, not both
|
|
15
|
-
- [ ] Validate: raise `ArgumentError` if both provided, or neither
|
|
16
|
-
- [ ] When `term_months` given: calculate `num_payments` from frequency
|
|
17
|
-
- monthly: `term_months`
|
|
18
|
-
- bi-weekly: `term_months * 2` (approx 26/year)
|
|
19
|
-
- weekly: `term_months * 52 / 12` (rounded)
|
|
20
|
-
- daily: calculate actual business/calendar days in the term
|
|
21
|
-
- [ ] When `num_payments` given: calculate end date by walking the payment schedule forward
|
|
22
|
-
- [ ] Remove the hardcoded `calculate_total_payments` lookup table
|
|
23
|
-
- [ ] Remove `validate_term_months!` (any positive integer is now valid)
|
|
24
|
-
|
|
25
|
-
### 1.2 New frequencies
|
|
26
|
-
- [ ] Add `:monthly` frequency — payment on same day each month (or last business day)
|
|
27
|
-
- [ ] Add `:biweekly` frequency — every 14 days
|
|
28
|
-
- [ ] Update `validate_frequency!` to accept all four
|
|
29
|
-
- [ ] Handle month-end edge cases for monthly (e.g., start Jan 31 — Feb payment is Feb 28)
|
|
30
|
-
|
|
31
|
-
### 1.3 Dynamic day calculation
|
|
32
|
-
- [ ] Replace static payment counts with actual date walking
|
|
33
|
-
- [ ] For daily + `bank_days_only`: count actual business days in the term
|
|
34
|
-
- [ ] For weekly/biweekly: walk forward by 7/14 days, skip to next bank day if needed
|
|
35
|
-
- [ ] For monthly: advance by calendar month, adjust for bank days
|
|
36
|
-
|
|
37
|
-
## Phase 2: Public API Cleanup
|
|
38
|
-
|
|
39
|
-
### 2.1 Expose schedule data
|
|
40
|
-
- [ ] Make `generate_schedule_data` public, rename to `schedule` or `to_a`
|
|
41
|
-
- [ ] Return frozen array of payment hashes
|
|
42
|
-
- [ ] Add `summary` method — returns hash with totals, dates, payment count
|
|
43
|
-
- [ ] Remove `send(:generate_schedule_data)` from README examples
|
|
44
|
-
|
|
45
|
-
### 2.2 Convenience methods
|
|
46
|
-
- [ ] `#end_date` — last payment date
|
|
47
|
-
- [ ] `#total_interest` — sum of all interest payments
|
|
48
|
-
- [ ] `#total_paid` — sum of all payments
|
|
49
|
-
- [ ] `#payment_amount` — make public (currently private)
|
|
50
|
-
|
|
51
|
-
## Phase 3: Extract Formatters
|
|
52
|
-
|
|
53
|
-
- [ ] Extract `ConsoleFormatter` — takes schedule data, renders table
|
|
54
|
-
- [ ] Extract `CsvFormatter` — takes schedule data, writes CSV
|
|
55
|
-
- [ ] `generate(output:)` delegates to formatter
|
|
56
|
-
- [ ] Engine class focuses on math only
|
|
57
|
-
|
|
58
|
-
## Phase 4: Polish
|
|
59
|
-
|
|
60
|
-
- [ ] Fix README broken markdown in Development section
|
|
61
|
-
- [ ] Update README examples for new API
|
|
62
|
-
- [ ] Update gemspec description
|
|
63
|
-
- [ ] Add CHANGELOG entry for v2.0
|
|
64
|
-
- [ ] Bump version
|
|
65
|
-
- [ ] Update Ruby requirement to >= 3.0
|
|
66
|
-
- [ ] Run full spec suite, update/add specs for new features
|
|
67
|
-
|
|
68
|
-
---
|
|
69
|
-
|
|
70
|
-
## Breaking changes (v2.0)
|
|
71
|
-
|
|
72
|
-
- `term_months` no longer restricted to [6, 9, 12, 15, 18]
|
|
73
|
-
- `generate_schedule_data` renamed to public method
|
|
74
|
-
- New frequencies may change default behavior for existing users (unlikely — additive)
|