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.
@@ -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