Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 99 additions & 11 deletions app/models/loan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,42 @@ class Loan < ApplicationRecord

def monthly_payment
return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != "fixed"
return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero?
return Money.new(0, account.currency) if original_balance.amount.zero? || term_months.zero?

annual_rate = interest_rate / 100.0
monthly_rate = annual_rate / 12.0

if monthly_rate.zero?
payment = account.loan.original_balance.amount / term_months
else
payment = (account.loan.original_balance.amount * monthly_rate * (1 + monthly_rate)**term_months) / ((1 + monthly_rate)**term_months - 1)
end

Money.new(payment.round, account.currency)
Money.new(exact_monthly_payment(original_balance.amount, monthly_rate).round(2), account.currency)
end

def original_balance
Money.new(account.first_valuation_amount, account.currency)
end

# Returns a French-style amortization schedule as an array of period hashes:
# { period:, payment_date:, beginning_balance:, payment:, interest:, principal:, ending_balance: }
# Returns [] when the loan does not have enough parameters to project a fixed-rate schedule.
def amortization_schedule
return @amortization_schedule if defined?(@amortization_schedule)

@amortization_schedule = build_amortization_schedule
end

def total_interest
schedule = amortization_schedule
return nil if schedule.empty?

Money.new(schedule.sum { |row| row[:interest].amount }, account.currency)
end

def total_payments
schedule = amortization_schedule
return nil if schedule.empty?

Money.new(schedule.sum { |row| row[:payment].amount }, account.currency)
end

def payoff_date
amortization_schedule.last&.dig(:payment_date)
end

class << self
def color
"#D444F1"
Expand All @@ -43,4 +61,74 @@ def classification
"liability"
end
end

private
# Schedule arithmetic is done end-to-end in BigDecimal so 360 iterations don't accumulate
# binary-floating-point drift. The final-period correction still absorbs any sub-cent
# rounding remainder so the loan zeroes out exactly.
DIVISION_PRECISION = 18
ZERO = BigDecimal("0").freeze

def build_amortization_schedule
return [] unless rate_type == "fixed"
return [] if term_months.nil? || term_months <= 0 || interest_rate.nil?

principal = original_balance.amount
return [] if principal.zero?

rate = monthly_rate
payment = exact_monthly_payment(principal, rate)
start_date = schedule_start_date
currency = account.currency

balance = principal
rows = []

(1..term_months).each do |period|
interest_amount = (balance * rate).round(2)
principal_amount = (payment - interest_amount).round(2)

# Absorb rounding drift in the final period so the balance lands exactly on zero.
if period == term_months || principal_amount > balance
principal_amount = balance.round(2)
end

payment_amount = (interest_amount + principal_amount).round(2)
Comment on lines +92 to +96
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop generating zero-payment rows after payoff

When rounding causes the balance to hit zero before the final term, the loop keeps emitting remaining periods with $0 payment/interest/principal instead of ending the schedule. Because payoff_date is taken from the last row, this can push the reported payoff later than the actual payoff month (e.g., small principals with long terms, such as balance=1000, interest_rate=9.4, term_months=360, reach zero at period 359 but still add period 360 as all zeros).

Useful? React with 👍 / 👎.

ending_balance = (balance - principal_amount).round(2)
ending_balance = ZERO if ending_balance.negative?

rows << {
period: period,
payment_date: start_date >> period,
beginning_balance: Money.new(balance.round(2), currency),
payment: Money.new(payment_amount, currency),
interest: Money.new(interest_amount, currency),
principal: Money.new(principal_amount, currency),
ending_balance: Money.new(ending_balance, currency)
}

balance = ending_balance
end

rows
end

def exact_monthly_payment(principal, monthly_rate)
if monthly_rate.zero?
principal.div(term_months, DIVISION_PRECISION)
else
factor = (1 + monthly_rate) ** term_months
principal.mul(monthly_rate, DIVISION_PRECISION)
.mul(factor, DIVISION_PRECISION)
.div(factor - 1, DIVISION_PRECISION)
end
end

def monthly_rate
(interest_rate.to_d / 100).div(12, DIVISION_PRECISION)
end

def schedule_start_date
account.first_valuation&.date || account.created_at&.to_date || Date.current
end
end
51 changes: 51 additions & 0 deletions app/views/loans/tabs/_overview.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,59 @@
<%= summary_card title: t(".type") do %>
<%= account.loan.rate_type&.titleize || t(".unknown") %>
<% end %>

<%= summary_card title: t(".total_interest") do %>
<% if account.loan.total_interest.present? %>
<%= format_money(account.loan.total_interest) %>
<% else %>
<%= t(".unknown") %>
<% end %>
<% end %>

<%= summary_card title: t(".payoff_date") do %>
<% if account.loan.payoff_date.present? %>
<%= I18n.l(account.loan.payoff_date, format: :long) %>
<% else %>
<%= t(".unknown") %>
<% end %>
<% end %>
</div>

<%# Show only upcoming rows so a 5-year-old mortgage doesn't open with a table dating back to origination. %>
<% upcoming_schedule = account.loan.amortization_schedule.select { |row| row[:payment_date] >= Date.current } %>
<% if upcoming_schedule.any? %>
<div class="mt-6">
<%= render DS::Disclosure.new(title: t(".amortization_schedule")) do %>
<div class="overflow-x-auto rounded-lg border border-primary">
<table class="min-w-full text-sm">
<thead class="bg-surface">
<tr class="text-left text-secondary">
<th scope="col" class="px-4 py-2 font-medium"><%= t(".schedule.period") %></th>
<th scope="col" class="px-4 py-2 font-medium"><%= t(".schedule.payment_date") %></th>
<th scope="col" class="px-4 py-2 font-medium text-right"><%= t(".schedule.payment") %></th>
<th scope="col" class="px-4 py-2 font-medium text-right"><%= t(".schedule.principal") %></th>
<th scope="col" class="px-4 py-2 font-medium text-right"><%= t(".schedule.interest") %></th>
<th scope="col" class="px-4 py-2 font-medium text-right"><%= t(".schedule.ending_balance") %></th>
</tr>
</thead>
<tbody>
<% upcoming_schedule.each do |row| %>
<tr class="border-t border-primary text-primary">
<td class="px-4 py-2"><%= row[:period] %></td>
<td class="px-4 py-2"><%= I18n.l(row[:payment_date], format: :short) %></td>
<td class="px-4 py-2 text-right"><%= format_money(row[:payment]) %></td>
<td class="px-4 py-2 text-right"><%= format_money(row[:principal]) %></td>
<td class="px-4 py-2 text-right"><%= format_money(row[:interest]) %></td>
<td class="px-4 py-2 text-right"><%= format_money(row[:ending_balance]) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
<% end %>

<div class="flex justify-center py-8">
<%= render DS::Link.new(
text: t(".edit_loan_details"),
Expand Down
10 changes: 10 additions & 0 deletions config/locales/views/loans/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,22 @@ en:
unknown: Unknown
tabs:
overview:
amortization_schedule: Amortization schedule
interest_rate: Interest Rate
monthly_payment: Monthly Payment
not_applicable: N/A
original_principal: Original Principal
payoff_date: Payoff Date
remaining_principal: Remaining Principal
schedule:
ending_balance: Balance
interest: Interest
payment: Payment
payment_date: Date
period: '#'
principal: Principal
term: Term
total_interest: Total Interest
type: Type
unknown: Unknown
edit_loan_details: "Edit loan details"
100 changes: 87 additions & 13 deletions test/models/loan_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,92 @@ class LoanTest < ActiveSupport::TestCase
end

test "calculates correct monthly payment for fixed rate loan" do
loan_account = Account.create! \
family: families(:dylan_family),
name: "Mortgage Loan",
balance: 500000,
currency: "USD",
accountable: Loan.create!(
subtype: "mortgage",
interest_rate: 3.5,
term_months: 360,
rate_type: "fixed"
)

assert_equal 2245, loan_account.loan.monthly_payment.amount
loan_account = build_loan_account(balance: 500000, interest_rate: 3.5, term_months: 360)

assert_in_delta 2245.22, loan_account.loan.monthly_payment.amount.to_f, 0.01
end

test "amortization schedule returns one row per term month" do
loan_account = build_loan_account(balance: 500000, interest_rate: 3.5, term_months: 360)

schedule = loan_account.loan.amortization_schedule

assert_equal 360, schedule.length
assert_equal 1, schedule.first[:period]
assert_equal 360, schedule.last[:period]
end

test "amortization schedule splits each payment into interest and principal" do
loan_account = build_loan_account(balance: 240000, interest_rate: 6.0, term_months: 360)

first = loan_account.loan.amortization_schedule.first

assert_equal 1200.00, first[:interest].amount.to_f
assert_in_delta 238.92, first[:principal].amount.to_f, 0.05
assert_in_delta first[:beginning_balance].amount.to_f - first[:principal].amount.to_f,
first[:ending_balance].amount.to_f, 0.01
end

test "amortization schedule pays the loan down to zero in the final period" do
loan_account = build_loan_account(balance: 240000, interest_rate: 6.0, term_months: 360)

last = loan_account.loan.amortization_schedule.last

assert_equal 0, last[:ending_balance].amount.to_f
end

test "amortization schedule handles zero interest" do
loan_account = build_loan_account(balance: 12000, interest_rate: 0, term_months: 12)

schedule = loan_account.loan.amortization_schedule

assert_equal 12, schedule.length
assert_equal 0, schedule.first[:interest].amount.to_f
assert_equal 1000, schedule.first[:principal].amount.to_f
assert_equal 0, schedule.last[:ending_balance].amount.to_f
end

test "amortization schedule is empty for non-fixed loans" do
loan_account = build_loan_account(balance: 100000, interest_rate: 4.0, term_months: 120, rate_type: "variable")

assert_empty loan_account.loan.amortization_schedule
assert_nil loan_account.loan.total_interest
assert_nil loan_account.loan.payoff_date
end

test "amortization schedule is empty when required attributes are missing" do
loan_account = build_loan_account(balance: 100000, interest_rate: nil, term_months: 120)

assert_empty loan_account.loan.amortization_schedule
end

test "total interest sums interest across the schedule" do
loan_account = build_loan_account(balance: 240000, interest_rate: 6.0, term_months: 360)

schedule = loan_account.loan.amortization_schedule
expected = schedule.sum { |row| row[:interest].amount }

assert_equal expected, loan_account.loan.total_interest.amount
end

test "payoff date equals the date of the final schedule row" do
loan_account = build_loan_account(balance: 240000, interest_rate: 6.0, term_months: 360)

assert_equal loan_account.loan.amortization_schedule.last[:payment_date], loan_account.loan.payoff_date
end

private
def build_loan_account(balance:, interest_rate:, term_months:, rate_type: "fixed")
Account.create! \
family: families(:dylan_family),
name: "Loan",
balance: balance,
currency: "USD",
accountable: Loan.create!(
subtype: "mortgage",
interest_rate: interest_rate,
term_months: term_months,
rate_type: rate_type
)
end
end