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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1bf3cd11455f5c2c301066d64f911a15e696dd1b033c2b865a5d7ddaff93e05
4
- data.tar.gz: 34482d2f3553108c4ad04d3dd238ff486e3c72aa266fd4f19582607720df98ff
3
+ metadata.gz: 4ca0ea325340402016df8375d37a1df326360b5c83757133ab91d7d90586d98b
4
+ data.tar.gz: d8323ccba488c8e3fd4ed75095ede9b84615011bcdd881e038b9e539ffc2d694
5
5
  SHA512:
6
- metadata.gz: e1d49aaa154f7dbce6a7f5e9fddf77c04a4f6ea90af9662b4cfa1c6ed787a70672277cd6a8425346277067fef6af76ac2d3ec32ddfd2f7b3d972f049cdd43846
7
- data.tar.gz: 94aaf0a040ba624cedc10ea49db5a873712b8682a6f6191439088eb02df8f74d27bb86c14cb9288607a51217d9f091ab643b3d4108fb83b3e04dcf8e28a11dd3
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
  [![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,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
- - **Testing**: 70 RSpec tests
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 # => 8791.59
65
- schedule.total_interest # => 5499.06
66
- schedule.total_paid # => 105499.06
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 with All Features
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: 8302.48,
161
- interest_payment: 489.11,
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: 8791.59,
164
- principal_balance: 91697.52,
165
- accrued_interest: 489.11,
166
- total_balance: 92186.63,
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: 29
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: 8791.59,
185
- total_interest: 5499.06,
186
- total_paid: 105499.06
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 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.
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 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.
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 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)).
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
- ## Command Line Interface
388
+ ## Commercial Financing Disclosures
329
389
 
330
- Amortizy includes an interactive CLI tool:
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
- ```bash
333
- amortizy
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
- The CLI provides:
337
- 1. Run default examples (demonstrates simple vs precomputed interest)
338
- 2. Enter parameters manually with interactive prompts
339
- 3. Automatic CSV generation option
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.0 or higher
344
- - `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
345
496
 
346
497
  ## Development
347
498
 
@@ -363,7 +514,7 @@ bin/console
363
514
 
364
515
  ## Testing
365
516
 
366
- The test suite includes 70 RSpec examples covering:
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
@@ -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
- attr_reader :start_date, :principal, :term_months, :annual_rate, :frequency, :additional_fee_label
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
- days_per_period = calculate_average_days_per_period
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 @additional_fee_treatment == :separate_payment && @additional_fee.positive?
357
- payment_date = next_payment_date(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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Amortizy
4
- VERSION = '2.0.0'
4
+ VERSION = '3.1.1'
5
5
  end
data/lib/amortizy.rb CHANGED
@@ -4,6 +4,7 @@ require_relative 'amortizy/version'
4
4
  require_relative 'amortizy/engine'
5
5
  require_relative 'amortizy/console_formatter'
6
6
  require_relative 'amortizy/csv_formatter'
7
+ require_relative 'amortizy/disclosure'
7
8
 
8
9
  module Amortizy
9
10
  class Error < StandardError; 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.0.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: []
@@ -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.0.0
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)