amortizy 2.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00f0ccad91127633326557a81254c429737b526a834bb18de2235b842390f7ef
4
- data.tar.gz: fcf4c2b4d999c1c276446fb36ea85984c7d380b4819b9592f55afcdb405985d2
3
+ metadata.gz: 4ca0ea325340402016df8375d37a1df326360b5c83757133ab91d7d90586d98b
4
+ data.tar.gz: d8323ccba488c8e3fd4ed75095ede9b84615011bcdd881e038b9e539ffc2d694
5
5
  SHA512:
6
- metadata.gz: 73e33d2c4a0ee6f0ab15d9618d4aa53df5bbbe16608630f125dac377e674e82bf8c576362e492257692c928b6ec0328781cd91c752a57b8d55c36946154fb804
7
- data.tar.gz: 0c9e25aabdceee720c8fb6fcf2367b101fbce214642e4be84022f0ea2ca166478ea2bbeb190018bca41c03fe63b5e87963e5c79f1fc23b4b333cea71d72d8314
6
+ metadata.gz: bfa7d476af355d10e128f26906c102f9fed268c8ac62ade499bb5cefb230d7288579d98e27970271250a7ad6e1d8c6f1fdb9d4230ab06d2ea1e0322af6706e19
7
+ data.tar.gz: b2c3bef7394514492942ff87f61f229f74a398337298a3bd9671a01c48ff914357fd50ba2146210b56c7907624b9667c6df491e76699239c2f180b78c0780e2c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ 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
+
8
40
  ## [2.1.0] - 2026-04-20
9
41
 
10
42
  ### Added
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Gem Version](https://img.shields.io/gem/v/amortizy.svg?style=flat)](https://rubygems.org/gems/amortizy)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- - **Testing**: 120 RSpec tests
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 # => 8791.59
66
- schedule.total_interest # => 5499.06
67
- schedule.total_paid # => 105499.06
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 with All Features
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: 8302.48,
162
- interest_payment: 489.11,
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: 8791.59,
165
- principal_balance: 91697.52,
166
- accrued_interest: 489.11,
167
- total_balance: 92186.63,
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: 29
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: 8791.59,
186
- total_interest: 5499.06,
187
- total_paid: 105499.06
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 before regular amortization begins.
239
+ The fee is collected as a separate payment (`payment_number: 0`) one period after the start, before regular amortization begins.
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 based on the original principal and divided equally across all payments. Interest per payment stays constant regardless of principal reduction.
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 rules are handled automatically by the `holidays` gem.
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.). Values are jurisdiction-agnostic — computed per the most stringent methodology, usable for any state.
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 # => 12.3 (Reg Z actuarial method, 10 bps precision)
354
- disclosure.finance_charge # => 11119.65
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 # => 60619.65
417
+ disclosure.total_payment_amount # => 60623.13
357
418
  disclosure.recipient_funds # => 49500.0
358
- disclosure.term_display # => "3 years, 0.20 months"
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: 12.3, finance_charge: 11119.65, ... }
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: 12.3 },
397
- # finance_charge: { label: "Finance Charge", value: 11119.65 },
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.0 or higher
446
- - `holidays` gem (~> 8.0) - automatically installed as a dependency
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 test suite includes 120 RSpec examples covering:
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
@@ -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
@@ -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
@@ -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
- days_per_period = calculate_average_days_per_period
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,62 @@ 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
+ 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
+
281
355
  def federal_holiday?(date)
282
356
  holidays = Holidays.on(date, :federalreserve, :observed)
283
357
  !holidays.empty?
@@ -330,7 +404,6 @@ module Amortizy
330
404
  payment_amount = calculate_payment
331
405
  balance = effective_principal
332
406
  accrued_interest = 0.0
333
- payment_date = first_payment_date
334
407
  previous_payment_date = first_payment_date
335
408
  payment_number = 0
336
409
  num = total_payments
@@ -354,8 +427,8 @@ module Amortizy
354
427
  }
355
428
  end
356
429
 
357
- if @additional_fee_treatment == :separate_payment && @additional_fee.positive?
358
- payment_date = next_payment_date(payment_date)
430
+ if separate_fee_payment?
431
+ payment_date = separate_fee_date
359
432
  schedule_data << {
360
433
  payment_number: 0,
361
434
  date: payment_date,
@@ -375,20 +448,10 @@ module Amortizy
375
448
  additional_fee_per_payment = @additional_fee_treatment == :distributed ? (@additional_fee / num) : 0.0
376
449
  precomputed_interest = @interest_method == :precomputed ? precomputed_interest_per_payment : 0.0
377
450
 
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
451
  while payment_number < num && balance > 0.01
452
+ payment_date = regular_payment_dates[payment_number]
383
453
  payment_number += 1
384
454
 
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
455
  days_in_period = calculate_days_between(previous_payment_date, payment_date)
393
456
 
394
457
  if @interest_method == :precomputed
@@ -407,7 +470,7 @@ module Amortizy
407
470
  else
408
471
  principal_payment = [payment_amount - interest_payment - additional_fee_per_payment, balance].min
409
472
  principal_payment = balance if payment_number == num
410
- payment_type = 'Regular Payment'
473
+ payment_type = payment_number == num && @balloon_amount.positive? ? 'Balloon Payment' : 'Regular Payment'
411
474
  end
412
475
 
413
476
  total_payment = principal_payment + interest_payment + additional_fee_per_payment
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Amortizy
4
- VERSION = '2.1.0'
4
+ VERSION = '3.1.1'
5
5
  end
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: 2.1.0
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: '8.0'
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: '8.0'
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, federal bank
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.0.0
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)