amortizy 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -1
- data/README.md +107 -2
- data/lib/amortizy/disclosure.rb +306 -0
- data/lib/amortizy/engine.rb +2 -1
- data/lib/amortizy/version.rb +1 -1
- data/lib/amortizy.rb +1 -0
- data/tasks/todo.md +193 -64
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00f0ccad91127633326557a81254c429737b526a834bb18de2235b842390f7ef
|
|
4
|
+
data.tar.gz: fcf4c2b4d999c1c276446fb36ea85984c7d380b4819b9592f55afcdb405985d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 73e33d2c4a0ee6f0ab15d9618d4aa53df5bbbe16608630f125dac377e674e82bf8c576362e492257692c928b6ec0328781cd91c752a57b8d55c36946154fb804
|
|
7
|
+
data.tar.gz: 0c9e25aabdceee720c8fb6fcf2367b101fbce214642e4be84022f0ea2ca166478ea2bbeb190018bca41c03fe63b5e87963e5c79f1fc23b4b333cea71d72d8314
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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
|
+
## [2.1.0] - 2026-04-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
#### Commercial Financing Disclosure Module
|
|
13
|
+
- **`Amortizy::Disclosure`** - new class that computes disclosure data elements for
|
|
14
|
+
closed-end commercial loan transactions per California SB 1235 (Title 10, Chapter 3)
|
|
15
|
+
- **APR calculation** using the actuarial method per Appendix J of Regulation Z
|
|
16
|
+
(12 CFR Part 1026, section 940) with Newton-Raphson solver and bisection fallback
|
|
17
|
+
- **Finance charge** - total dollar cost of financing including interest and all fees (section 943)
|
|
18
|
+
- **Amount financed** - principal adjusted for prepaid finance charges (section 900(a)(1)(B))
|
|
19
|
+
- **Recipient funds** - net amount disbursed to borrower after third-party payoffs (section 900(a)(26))
|
|
20
|
+
- **Term display** - formatted per regulation: days for terms <= 1 year,
|
|
21
|
+
years/months for longer terms (section 901(a)(4))
|
|
22
|
+
- **Average monthly cost** - automatically computed for non-monthly payment frequencies (section 910(a)(12))
|
|
23
|
+
- **Prepayment information** - structured data for prepayment penalty disclosures (section 910(a)(8-10))
|
|
24
|
+
- **`#to_h`** - flat hash of all computed values
|
|
25
|
+
- **`#to_labeled_h`** - hash with regulation-correct labels from section 910
|
|
26
|
+
|
|
27
|
+
#### Design Notes
|
|
28
|
+
- Disclosure class wraps an existing `AmortizationSchedule` — no changes to the engine API
|
|
29
|
+
- Values are jurisdiction-agnostic: computed per the most stringent methodology (California),
|
|
30
|
+
usable for any state's commercial financing disclosure requirements
|
|
31
|
+
- Origination fee is treated as a prepaid finance charge by default
|
|
32
|
+
- APR rounded to nearest 10 basis points per section 901(a)(5)
|
|
33
|
+
- APR accuracy within 1/8 of 1 percentage point tolerance per section 955
|
|
34
|
+
|
|
8
35
|
## [2.0.0] - 2026-04-19
|
|
9
36
|
|
|
10
37
|
### Added
|
|
@@ -127,7 +154,6 @@ bundle update amortizy
|
|
|
127
154
|
|
|
128
155
|
Potential features for future releases:
|
|
129
156
|
|
|
130
|
-
- Additional payment frequencies (monthly, bi-weekly)
|
|
131
157
|
- More interest calculation methods
|
|
132
158
|
- Support for additional holiday calendars (state, international)
|
|
133
159
|
- Balloon payment support
|
data/README.md
CHANGED
|
@@ -19,7 +19,8 @@ Perfect for financial applications, lending platforms, and loan calculators.
|
|
|
19
19
|
- **Bank day calculations**: Automatically skip weekends and US Federal Reserve holidays
|
|
20
20
|
- **Multiple output formats**: Console display or CSV export
|
|
21
21
|
- **Public API**: Programmatic access to schedule data, summaries, and totals
|
|
22
|
-
- **
|
|
22
|
+
- **Commercial financing disclosures**: APR (Reg Z actuarial method), finance charge, amount financed, and more
|
|
23
|
+
- **Testing**: 120 RSpec tests
|
|
23
24
|
|
|
24
25
|
## Installation
|
|
25
26
|
|
|
@@ -325,6 +326,107 @@ schedule = Amortizy::AmortizationSchedule.new(
|
|
|
325
326
|
schedule.generate(output: :csv, csv_path: "commercial_loan.csv")
|
|
326
327
|
```
|
|
327
328
|
|
|
329
|
+
## Commercial Financing Disclosures
|
|
330
|
+
|
|
331
|
+
Amortizy includes a disclosure module for computing data elements required by California SB 1235 and similar state commercial financing disclosure laws (New York, Georgia, etc.). Values are jurisdiction-agnostic — computed per the most stringent methodology, usable for any state.
|
|
332
|
+
|
|
333
|
+
The `Disclosure` class wraps an existing `AmortizationSchedule` and computes nine disclosure elements. The gem computes the numbers — your application handles document formatting.
|
|
334
|
+
|
|
335
|
+
**Key assumption:** The origination fee is treated as a prepaid finance charge (deducted from amount financed, included in finance charge). This is the standard treatment for commercial lending.
|
|
336
|
+
|
|
337
|
+
### Basic Usage
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
schedule = Amortizy::AmortizationSchedule.new(
|
|
341
|
+
start_date: "2026-01-15",
|
|
342
|
+
principal: 50_000,
|
|
343
|
+
annual_rate: 12.0,
|
|
344
|
+
frequency: :monthly,
|
|
345
|
+
term_months: 36,
|
|
346
|
+
origination_fee: 500,
|
|
347
|
+
additional_fee: 250,
|
|
348
|
+
additional_fee_treatment: :distributed
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
disclosure = Amortizy::Disclosure.new(schedule)
|
|
352
|
+
|
|
353
|
+
disclosure.apr # => 12.3 (Reg Z actuarial method, 10 bps precision)
|
|
354
|
+
disclosure.finance_charge # => 11119.65
|
|
355
|
+
disclosure.amount_financed # => 49500.0
|
|
356
|
+
disclosure.total_payment_amount # => 60619.65
|
|
357
|
+
disclosure.recipient_funds # => 49500.0
|
|
358
|
+
disclosure.term_display # => "3 years, 0.20 months"
|
|
359
|
+
disclosure.average_monthly_cost # => nil (monthly frequency)
|
|
360
|
+
disclosure.prepayment # => { has_non_interest_charges: false, ... }
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### With Third-Party Payments and Prepayment Info
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
disclosure = Amortizy::Disclosure.new(
|
|
367
|
+
schedule,
|
|
368
|
+
third_party_payments: 10_000,
|
|
369
|
+
prepayment_penalty_max: 1_200,
|
|
370
|
+
additional_prepayment_fees: [
|
|
371
|
+
{ amount: 250.0, description: "Early termination fee" }
|
|
372
|
+
]
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
disclosure.recipient_funds # => 39500.0 (amount_financed - third_party_payments)
|
|
376
|
+
disclosure.prepayment
|
|
377
|
+
# => {
|
|
378
|
+
# has_non_interest_charges: true,
|
|
379
|
+
# max_non_interest_finance_charge: 1200.0,
|
|
380
|
+
# has_additional_fees: true,
|
|
381
|
+
# additional_fees: [{ amount: 250.0, description: "Early termination fee" }]
|
|
382
|
+
# }
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Output Formats
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
# Raw values
|
|
389
|
+
disclosure.to_h
|
|
390
|
+
# => { amount_financed: 49500.0, apr: 12.3, finance_charge: 11119.65, ... }
|
|
391
|
+
|
|
392
|
+
# With regulation-correct labels (per CA SB 1235 section 910)
|
|
393
|
+
disclosure.to_labeled_h
|
|
394
|
+
# => {
|
|
395
|
+
# amount_financed: { label: "Funding Provided", value: 49500.0 },
|
|
396
|
+
# apr: { label: "Annual Percentage Rate (APR)", value: 12.3 },
|
|
397
|
+
# finance_charge: { label: "Finance Charge", value: 11119.65 },
|
|
398
|
+
# ...
|
|
399
|
+
# }
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Disclosure Parameters
|
|
403
|
+
|
|
404
|
+
| Parameter | Type | Default | Description |
|
|
405
|
+
|-----------|------|---------|-------------|
|
|
406
|
+
| `third_party_payments` | Float | 0 | Amounts paid to third parties from the financing |
|
|
407
|
+
| `prepayment_penalty_max` | Float | 0 | Maximum non-interest charge on early payoff |
|
|
408
|
+
| `additional_prepayment_fees` | Array | [] | Array of `{ amount:, description: }` hashes |
|
|
409
|
+
|
|
410
|
+
### Disclosure Elements
|
|
411
|
+
|
|
412
|
+
| Element | Method | Description | Regulatory Reference |
|
|
413
|
+
|---------|--------|-------------|---------------------|
|
|
414
|
+
| Funding Provided | `#amount_financed` | Principal adjusted for prepaid finance charges | §900(a)(1)(B) |
|
|
415
|
+
| APR | `#apr` | Annual percentage rate (Reg Z actuarial method) | §940 |
|
|
416
|
+
| Finance Charge | `#finance_charge` | Total dollar cost (derived from Reg Z identity) | §943 |
|
|
417
|
+
| Total Payment Amount | `#total_payment_amount` | Sum of all borrower payments | §910(a)(5) |
|
|
418
|
+
| Payment | `#payment_amount` | Regular periodic payment | §910(a)(6) |
|
|
419
|
+
| Term | `#term_display` | Days (<=1yr) or years/months (>1yr) | §901(a)(4) |
|
|
420
|
+
| Average Monthly Cost | `#average_monthly_cost` | For non-monthly frequencies only | §910(a)(12) |
|
|
421
|
+
| Recipient Funds | `#recipient_funds` | Net amount to borrower | §900(a)(26) |
|
|
422
|
+
| Prepayment | `#prepayment` | Prepayment penalty details | §910(a)(8-10) |
|
|
423
|
+
|
|
424
|
+
### Technical Notes
|
|
425
|
+
|
|
426
|
+
- **APR calculation** uses the actuarial method per Appendix J of Regulation Z (12 CFR Part 1026). Newton-Raphson solver with bisection fallback. APR rounded to nearest 10 basis points per §901(a)(5). Accuracy within 1/8 of 1 percentage point per §955.
|
|
427
|
+
- **Finance charge** is derived from the Reg Z identity: `total_payment_amount - amount_financed`. This guarantees the three core disclosure values are always internally consistent.
|
|
428
|
+
- **Floating-point arithmetic** is used for all calculations. This is adequate for typical commercial loans. For extremely large principals or very long terms (1000+ payments), consider validating against a reference APR calculator.
|
|
429
|
+
|
|
328
430
|
## Command Line Interface
|
|
329
431
|
|
|
330
432
|
Amortizy includes an interactive CLI tool:
|
|
@@ -363,7 +465,7 @@ bin/console
|
|
|
363
465
|
|
|
364
466
|
## Testing
|
|
365
467
|
|
|
366
|
-
The test suite includes
|
|
468
|
+
The test suite includes 120 RSpec examples covering:
|
|
367
469
|
|
|
368
470
|
- Initialization and validation
|
|
369
471
|
- Payment calculations for all four frequencies
|
|
@@ -379,6 +481,9 @@ The test suite includes 70 RSpec examples covering:
|
|
|
379
481
|
- Public API (schedule, summary, convenience methods)
|
|
380
482
|
- Deep freeze immutability
|
|
381
483
|
- Edge cases (year boundaries, month-end clamping, input validation)
|
|
484
|
+
- Commercial financing disclosures (APR, finance charge, amount financed, all nine elements)
|
|
485
|
+
- Reg Z identity verification across all fee treatment modes
|
|
486
|
+
- APR solver convergence (grace periods, interest-only, short-term, zero-rate loans)
|
|
382
487
|
|
|
383
488
|
Run the test suite:
|
|
384
489
|
|
|
@@ -0,0 +1,306 @@
|
|
|
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
|
+
# --- Payment Frequency ---
|
|
74
|
+
|
|
75
|
+
def payment_frequency
|
|
76
|
+
@schedule.frequency
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# --- Recipient Funds (§900(a)(26)) ---
|
|
80
|
+
# Net amount given directly to the borrower.
|
|
81
|
+
|
|
82
|
+
def recipient_funds
|
|
83
|
+
amount_financed - @third_party_payments
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# --- Term (§901(a)(4)) ---
|
|
87
|
+
|
|
88
|
+
def term_days
|
|
89
|
+
@term_days ||= (@schedule.end_date - disbursement_date).to_i
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def term_display
|
|
93
|
+
if term_days <= 365
|
|
94
|
+
"#{term_days} days"
|
|
95
|
+
else
|
|
96
|
+
years = term_days / 365
|
|
97
|
+
remaining_days = term_days % 365
|
|
98
|
+
months = (remaining_days / 30.4).round(2)
|
|
99
|
+
year_label = years == 1 ? 'year' : 'years'
|
|
100
|
+
month_label = (months - 1.0).abs < 0.005 ? 'month' : 'months'
|
|
101
|
+
format('%d %s, %.2f %s', years, year_label, months, month_label)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- Average Monthly Cost (§910(a)(12)) ---
|
|
106
|
+
# Only for non-monthly payment frequencies.
|
|
107
|
+
|
|
108
|
+
def average_monthly_cost
|
|
109
|
+
return nil if @schedule.frequency == :monthly
|
|
110
|
+
return nil if term_days <= 0
|
|
111
|
+
|
|
112
|
+
total_payment_amount / (term_days / 30.4)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# --- APR (§940) ---
|
|
116
|
+
# Actuarial method per Appendix J, Regulation Z (12 CFR Part 1026).
|
|
117
|
+
# Rounded to nearest 10 basis points per §901(a)(5).
|
|
118
|
+
|
|
119
|
+
def apr
|
|
120
|
+
@apr ||= begin
|
|
121
|
+
raw_apr = calculate_apr
|
|
122
|
+
round_to_ten_basis_points(raw_apr)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --- Prepayment (§910(a)(8-10)) ---
|
|
127
|
+
|
|
128
|
+
def prepayment
|
|
129
|
+
@prepayment ||= {
|
|
130
|
+
has_non_interest_charges: @prepayment_penalty_max.positive?,
|
|
131
|
+
max_non_interest_finance_charge: @prepayment_penalty_max.positive? ? @prepayment_penalty_max : nil,
|
|
132
|
+
has_additional_fees: @additional_prepayment_fees.any?,
|
|
133
|
+
additional_fees: @additional_prepayment_fees
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# --- Output Methods ---
|
|
138
|
+
|
|
139
|
+
def to_h
|
|
140
|
+
{
|
|
141
|
+
amount_financed: amount_financed.round(2),
|
|
142
|
+
apr: apr,
|
|
143
|
+
finance_charge: finance_charge.round(2),
|
|
144
|
+
total_payment_amount: total_payment_amount.round(2),
|
|
145
|
+
payment_amount: payment_amount.round(2),
|
|
146
|
+
payment_frequency: payment_frequency,
|
|
147
|
+
term_days: term_days,
|
|
148
|
+
term_display: term_display,
|
|
149
|
+
average_monthly_cost: average_monthly_cost&.round(2),
|
|
150
|
+
recipient_funds: recipient_funds.round(2),
|
|
151
|
+
prepayment: prepayment
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def to_labeled_h
|
|
156
|
+
{
|
|
157
|
+
amount_financed: { label: 'Funding Provided', value: amount_financed.round(2) },
|
|
158
|
+
apr: { label: 'Annual Percentage Rate (APR)', value: apr },
|
|
159
|
+
finance_charge: { label: 'Finance Charge', value: finance_charge.round(2) },
|
|
160
|
+
total_payment_amount: { label: 'Total Payment Amount', value: total_payment_amount.round(2) },
|
|
161
|
+
payment_amount: { label: 'Payment', value: payment_amount.round(2) },
|
|
162
|
+
payment_frequency: { label: 'Payment Frequency', value: payment_frequency },
|
|
163
|
+
term_days: { label: 'Term', value: term_display },
|
|
164
|
+
average_monthly_cost: { label: 'Average Monthly Cost', value: average_monthly_cost&.round(2) },
|
|
165
|
+
recipient_funds: { label: 'Recipient Funds', value: recipient_funds.round(2) },
|
|
166
|
+
prepayment: prepayment
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def disbursement_date
|
|
173
|
+
@schedule.start_date
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def principal
|
|
177
|
+
@schedule.principal
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def origination_fee
|
|
181
|
+
@schedule.origination_fee
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def additional_fee
|
|
185
|
+
@schedule.additional_fee
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def fee_treatment
|
|
189
|
+
@schedule.additional_fee_treatment
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# --- APR Calculation ---
|
|
193
|
+
#
|
|
194
|
+
# Solves for the annual rate i such that:
|
|
195
|
+
# Amount Financed = Σ (Payment_k / (1 + i/365)^(days_k))
|
|
196
|
+
#
|
|
197
|
+
# where days_k is the number of days from disbursement to payment k.
|
|
198
|
+
#
|
|
199
|
+
# Uses Newton-Raphson with bisection fallback.
|
|
200
|
+
|
|
201
|
+
def calculate_apr
|
|
202
|
+
cash_flows = extract_cash_flows
|
|
203
|
+
return 0.0 if cash_flows.empty?
|
|
204
|
+
return 0.0 if amount_financed <= 0
|
|
205
|
+
return 0.0 if cash_flows.length > 10_000
|
|
206
|
+
|
|
207
|
+
# Initial guess: use the nominal annual rate as starting point
|
|
208
|
+
guess = @schedule.annual_rate
|
|
209
|
+
guess = 0.10 if guess.zero?
|
|
210
|
+
|
|
211
|
+
apr_newton_raphson(cash_flows, guess) || apr_bisection(cash_flows)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def extract_cash_flows
|
|
215
|
+
flows = []
|
|
216
|
+
start = disbursement_date
|
|
217
|
+
|
|
218
|
+
@schedule.schedule.each do |row|
|
|
219
|
+
payment = row[:total_payment]
|
|
220
|
+
next if payment.nil? || payment <= 0
|
|
221
|
+
|
|
222
|
+
days_from_start = (row[:date] - start).to_i
|
|
223
|
+
next if days_from_start <= 0
|
|
224
|
+
|
|
225
|
+
flows << { days: days_from_start, amount: payment }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
flows
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Present value of all payments at annual rate r, minus amount financed.
|
|
232
|
+
# When this equals zero, r is the APR.
|
|
233
|
+
def pv_function(cash_flows, annual_rate)
|
|
234
|
+
daily_rate = annual_rate / 365.0
|
|
235
|
+
pv = cash_flows.sum do |cf|
|
|
236
|
+
cf[:amount] / ((1.0 + daily_rate)**cf[:days])
|
|
237
|
+
end
|
|
238
|
+
pv - amount_financed
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Derivative of the PV function with respect to annual_rate.
|
|
242
|
+
def pv_derivative(cash_flows, annual_rate)
|
|
243
|
+
daily_rate = annual_rate / 365.0
|
|
244
|
+
cash_flows.sum do |cf|
|
|
245
|
+
-cf[:days] * cf[:amount] / (365.0 * ((1.0 + daily_rate)**(cf[:days] + 1)))
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def apr_newton_raphson(cash_flows, initial_guess)
|
|
250
|
+
rate = initial_guess
|
|
251
|
+
|
|
252
|
+
APR_MAX_ITERATIONS.times do
|
|
253
|
+
f = pv_function(cash_flows, rate)
|
|
254
|
+
f_prime = pv_derivative(cash_flows, rate)
|
|
255
|
+
|
|
256
|
+
return rate * 100.0 if f.abs < APR_CONVERGENCE_TOLERANCE
|
|
257
|
+
return nil if f_prime.abs < 1e-15 # derivative too small, switch to bisection
|
|
258
|
+
|
|
259
|
+
step = f / f_prime
|
|
260
|
+
rate -= step
|
|
261
|
+
|
|
262
|
+
# Guard against negative rates or runaway
|
|
263
|
+
return nil if rate <= -1.0 / 365.0
|
|
264
|
+
return nil if rate > 10.0 # 1000% APR cap — something went wrong
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
nil # didn't converge
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def apr_bisection(cash_flows)
|
|
271
|
+
low = -0.001
|
|
272
|
+
high = 10.0
|
|
273
|
+
|
|
274
|
+
# Ensure the interval brackets the root
|
|
275
|
+
f_low = pv_function(cash_flows, low)
|
|
276
|
+
f_high = pv_function(cash_flows, high)
|
|
277
|
+
|
|
278
|
+
# If both same sign, try expanding
|
|
279
|
+
if (f_low * f_high).positive?
|
|
280
|
+
high = 50.0
|
|
281
|
+
f_high = pv_function(cash_flows, high)
|
|
282
|
+
return 0.0 if (f_low * f_high).positive?
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
(APR_MAX_ITERATIONS * 5).times do
|
|
286
|
+
mid = (low + high) / 2.0
|
|
287
|
+
f_mid = pv_function(cash_flows, mid)
|
|
288
|
+
|
|
289
|
+
return mid * 100.0 if f_mid.abs < APR_CONVERGENCE_TOLERANCE
|
|
290
|
+
|
|
291
|
+
if (f_low * f_mid).negative?
|
|
292
|
+
high = mid
|
|
293
|
+
else
|
|
294
|
+
low = mid
|
|
295
|
+
f_low = f_mid
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
((low + high) / 2.0) * 100.0
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def round_to_ten_basis_points(apr_percent)
|
|
303
|
+
(apr_percent * 10).round / 10.0
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
data/lib/amortizy/engine.rb
CHANGED
|
@@ -16,7 +16,8 @@ require 'holidays'
|
|
|
16
16
|
|
|
17
17
|
module Amortizy
|
|
18
18
|
class AmortizationSchedule
|
|
19
|
-
attr_reader :start_date, :principal, :term_months, :annual_rate, :frequency, :additional_fee_label
|
|
19
|
+
attr_reader :start_date, :principal, :term_months, :annual_rate, :frequency, :additional_fee_label,
|
|
20
|
+
:origination_fee, :additional_fee, :additional_fee_treatment
|
|
20
21
|
|
|
21
22
|
def initialize(start_date:, principal:, annual_rate:, frequency:, term_months: nil, num_payments: nil,
|
|
22
23
|
origination_fee: 0, additional_fee: 0, additional_fee_label: 'Additional Fee',
|
data/lib/amortizy/version.rb
CHANGED
data/lib/amortizy.rb
CHANGED
data/tasks/todo.md
CHANGED
|
@@ -1,74 +1,203 @@
|
|
|
1
|
-
# Amortizy v2.
|
|
1
|
+
# Amortizy v2.1 — Commercial Financing Disclosure Module
|
|
2
2
|
|
|
3
3
|
## Goal
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
(
|
|
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)
|
|
8
25
|
|
|
9
26
|
---
|
|
10
27
|
|
|
11
|
-
## Phase 1:
|
|
12
|
-
|
|
13
|
-
### 1.1
|
|
14
|
-
- [ ]
|
|
15
|
-
- [ ]
|
|
16
|
-
- [ ]
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- [ ]
|
|
23
|
-
- [ ]
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
### 1
|
|
32
|
-
- [ ]
|
|
33
|
-
- [ ]
|
|
34
|
-
- [ ]
|
|
35
|
-
- [ ]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
- [ ]
|
|
41
|
-
- [ ]
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- [ ] `#
|
|
48
|
-
- [ ]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- [ ]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- [ ]
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- [ ]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
- [ ]
|
|
65
|
-
- [ ]
|
|
66
|
-
- [ ]
|
|
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)
|
|
67
140
|
|
|
68
141
|
---
|
|
69
142
|
|
|
70
|
-
##
|
|
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
|
+
---
|
|
71
192
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
-
|
|
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)
|
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.
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rich Zapata
|
|
@@ -40,6 +40,7 @@ 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
|