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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1bf3cd11455f5c2c301066d64f911a15e696dd1b033c2b865a5d7ddaff93e05
4
- data.tar.gz: 34482d2f3553108c4ad04d3dd238ff486e3c72aa266fd4f19582607720df98ff
3
+ metadata.gz: 00f0ccad91127633326557a81254c429737b526a834bb18de2235b842390f7ef
4
+ data.tar.gz: fcf4c2b4d999c1c276446fb36ea85984c7d380b4819b9592f55afcdb405985d2
5
5
  SHA512:
6
- metadata.gz: e1d49aaa154f7dbce6a7f5e9fddf77c04a4f6ea90af9662b4cfa1c6ed787a70672277cd6a8425346277067fef6af76ac2d3ec32ddfd2f7b3d972f049cdd43846
7
- data.tar.gz: 94aaf0a040ba624cedc10ea49db5a873712b8682a6f6191439088eb02df8f74d27bb86c14cb9288607a51217d9f091ab643b3d4108fb83b3e04dcf8e28a11dd3
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
- - **Testing**: 70 RSpec tests
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 70 RSpec examples covering:
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
@@ -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',
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Amortizy
4
- VERSION = '2.0.0'
4
+ VERSION = '2.1.0'
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
data/tasks/todo.md CHANGED
@@ -1,74 +1,203 @@
1
- # Amortizy v2.0 — Dynamic Payment Model
1
+ # Amortizy v2.1 — Commercial Financing Disclosure Module
2
2
 
3
3
  ## Goal
4
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.
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: 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
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
- ## Breaking changes (v2.0)
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
- - `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)
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.0.0
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