amortizy 1.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 +168 -3
- data/CONTRIBUTING.md +290 -0
- data/LICENSE.txt +1 -1
- data/README.md +490 -14
- data/Rakefile +3 -3
- data/lib/amortizy/console_formatter.rb +70 -0
- data/lib/amortizy/csv_formatter.rb +43 -0
- data/lib/amortizy/disclosure.rb +306 -0
- data/lib/amortizy/engine.rb +201 -278
- data/lib/amortizy/version.rb +1 -1
- data/lib/amortizy.rb +5 -2
- data/tasks/todo.md +203 -0
- metadata +12 -9
- data/.DS_Store +0 -0
- data/CODE_OF_CONDUCT.md +0 -132
- data/lib/.DS_Store +0 -0
|
@@ -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
|