From c00a8f849d5879f527ae096c0c5bb1416cddf24f Mon Sep 17 00:00:00 2001 From: rene0422 Date: Wed, 20 May 2026 16:00:58 -0700 Subject: [PATCH 1/4] feat(loans): native amortization schedule for fixed-rate loans (#1804) --- app/models/loan.rb | 85 ++++++++++++++++++++++ app/views/loans/tabs/_overview.html.erb | 52 ++++++++++++++ config/locales/views/loans/en.yml | 10 +++ test/models/loan_test.rb | 96 ++++++++++++++++++++++--- 4 files changed, 232 insertions(+), 11 deletions(-) diff --git a/app/models/loan.rb b/app/models/loan.rb index 980c19885..9d53e7e4b 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -30,6 +30,33 @@ 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" @@ -43,4 +70,62 @@ def classification "liability" end end + + private + def build_amortization_schedule + return [] unless rate_type == "fixed" + return [] if term_months.nil? || term_months <= 0 || interest_rate.nil? + + principal = original_balance.amount.to_f + return [] if principal.zero? + + monthly_rate = (interest_rate.to_f / 100.0) / 12.0 + payment = exact_monthly_payment_dollars(principal, monthly_rate) + start_date = schedule_start_date + currency = account.currency + + balance = principal + rows = [] + + (1..term_months).each do |period| + interest_amount = (balance * monthly_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) + ending_balance = (balance - principal_amount).round(2) + ending_balance = 0.0 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_dollars(principal, monthly_rate) + if monthly_rate.zero? + (principal / term_months).round(2) + else + factor = (1 + monthly_rate)**term_months + ((principal * monthly_rate * factor) / (factor - 1)).round(2) + end + end + + def schedule_start_date + account.first_valuation&.date || account.created_at&.to_date || Date.current + end end diff --git a/app/views/loans/tabs/_overview.html.erb b/app/views/loans/tabs/_overview.html.erb index 2c6d3810a..41791624f 100644 --- a/app/views/loans/tabs/_overview.html.erb +++ b/app/views/loans/tabs/_overview.html.erb @@ -42,8 +42,60 @@ <%= 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 %> +<% schedule = account.loan.amortization_schedule %> +<% if schedule.any? %> +
+ + <%= t(".amortization_schedule") %> + + +
+ + + + + + + + + + + + + <% schedule.each do |row| %> + + + + + + + + + <% end %> + +
<%= t(".schedule.period") %><%= t(".schedule.payment_date") %><%= t(".schedule.payment") %><%= t(".schedule.principal") %><%= t(".schedule.interest") %><%= t(".schedule.ending_balance") %>
<%= row[:period] %><%= I18n.l(row[:payment_date], format: :short) %><%= format_money(row[:payment]) %><%= format_money(row[:principal]) %><%= format_money(row[:interest]) %><%= format_money(row[:ending_balance]) %>
+
+
+<% end %> +
<%= render DS::Link.new( text: t(".edit_loan_details"), diff --git a/config/locales/views/loans/en.yml b/config/locales/views/loans/en.yml index 1c60d31d0..a76fd281c 100644 --- a/config/locales/views/loans/en.yml +++ b/config/locales/views/loans/en.yml @@ -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" diff --git a/test/models/loan_test.rb b/test/models/loan_test.rb index 05ce9fa05..28394debd 100644 --- a/test/models/loan_test.rb +++ b/test/models/loan_test.rb @@ -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" - ) + loan_account = build_loan_account(balance: 500000, interest_rate: 3.5, term_months: 360) assert_equal 2245, loan_account.loan.monthly_payment.amount 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_equal first[:beginning_balance].amount.to_f - first[:principal].amount.to_f, + first[:ending_balance].amount.to_f + 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 From c5e1463dbc2a96e20af089fa2a8eae02431d054a Mon Sep 17 00:00:00 2001 From: rene0422 Date: Wed, 20 May 2026 17:59:39 -0700 Subject: [PATCH 2/4] test(loans): tolerate float drift in amortization balance assertion --- test/models/loan_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/models/loan_test.rb b/test/models/loan_test.rb index 28394debd..e0f660157 100644 --- a/test/models/loan_test.rb +++ b/test/models/loan_test.rb @@ -31,8 +31,8 @@ class LoanTest < ActiveSupport::TestCase assert_equal 1200.00, first[:interest].amount.to_f assert_in_delta 238.92, first[:principal].amount.to_f, 0.05 - assert_equal first[:beginning_balance].amount.to_f - first[:principal].amount.to_f, - first[:ending_balance].amount.to_f + 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 From 8c6009225923deed82484bd7d5ac2b72585583ff Mon Sep 17 00:00:00 2001 From: rene0422 Date: Wed, 20 May 2026 19:40:34 -0700 Subject: [PATCH 3/4] refactor(loans): tighten amortization math, schedule UX, and disclosure --- app/models/loan.rb | 24 ++++++--- app/views/loans/tabs/_overview.html.erb | 65 ++++++++++++------------- 2 files changed, 48 insertions(+), 41 deletions(-) diff --git a/app/models/loan.rb b/app/models/loan.rb index 9d53e7e4b..36344ef0e 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -72,15 +72,21 @@ def classification 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.to_f + principal = original_balance.amount return [] if principal.zero? - monthly_rate = (interest_rate.to_f / 100.0) / 12.0 - payment = exact_monthly_payment_dollars(principal, monthly_rate) + monthly_rate = (interest_rate.to_d / 100).div(12, DIVISION_PRECISION) + payment = exact_monthly_payment(principal, monthly_rate) start_date = schedule_start_date currency = account.currency @@ -98,7 +104,7 @@ def build_amortization_schedule payment_amount = (interest_amount + principal_amount).round(2) ending_balance = (balance - principal_amount).round(2) - ending_balance = 0.0 if ending_balance.negative? + ending_balance = ZERO if ending_balance.negative? rows << { period: period, @@ -116,12 +122,14 @@ def build_amortization_schedule rows end - def exact_monthly_payment_dollars(principal, monthly_rate) + def exact_monthly_payment(principal, monthly_rate) if monthly_rate.zero? - (principal / term_months).round(2) + principal.div(term_months, DIVISION_PRECISION) else - factor = (1 + monthly_rate)**term_months - ((principal * monthly_rate * factor) / (factor - 1)).round(2) + factor = (1 + monthly_rate) ** term_months + principal.mul(monthly_rate, DIVISION_PRECISION) + .mul(factor, DIVISION_PRECISION) + .div(factor - 1, DIVISION_PRECISION) end end diff --git a/app/views/loans/tabs/_overview.html.erb b/app/views/loans/tabs/_overview.html.erb index 41791624f..c1026612f 100644 --- a/app/views/loans/tabs/_overview.html.erb +++ b/app/views/loans/tabs/_overview.html.erb @@ -60,40 +60,39 @@ <% end %>
-<% schedule = account.loan.amortization_schedule %> -<% if schedule.any? %> -
- - <%= t(".amortization_schedule") %> - - -
- - - - - - - - - - - - - <% schedule.each do |row| %> - - - - - - - +<%# 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? %> +
+ <%= render DS::Disclosure.new(title: t(".amortization_schedule")) do %> +
+
<%= t(".schedule.period") %><%= t(".schedule.payment_date") %><%= t(".schedule.payment") %><%= t(".schedule.principal") %><%= t(".schedule.interest") %><%= t(".schedule.ending_balance") %>
<%= row[:period] %><%= I18n.l(row[:payment_date], format: :short) %><%= format_money(row[:payment]) %><%= format_money(row[:principal]) %><%= format_money(row[:interest]) %><%= format_money(row[:ending_balance]) %>
+ + + + + + + + - <% end %> - -
<%= t(".schedule.period") %><%= t(".schedule.payment_date") %><%= t(".schedule.payment") %><%= t(".schedule.principal") %><%= t(".schedule.interest") %><%= t(".schedule.ending_balance") %>
-
-
+ + + <% upcoming_schedule.each do |row| %> + + <%= row[:period] %> + <%= I18n.l(row[:payment_date], format: :short) %> + <%= format_money(row[:payment]) %> + <%= format_money(row[:principal]) %> + <%= format_money(row[:interest]) %> + <%= format_money(row[:ending_balance]) %> + + <% end %> + + + + <% end %> + <% end %>
From 0783cbc4bcca87dfd4cde3699274b716686ded6e Mon Sep 17 00:00:00 2001 From: rene0422 Date: Wed, 20 May 2026 21:01:45 -0700 Subject: [PATCH 4/4] fix(loans): unify monthly_payment with schedule arithmetic --- app/models/loan.rb | 23 +++++++++-------------- test/models/loan_test.rb | 2 +- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/app/models/loan.rb b/app/models/loan.rb index 36344ef0e..81433d2a6 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -12,18 +12,9 @@ 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 @@ -85,8 +76,8 @@ def build_amortization_schedule principal = original_balance.amount return [] if principal.zero? - monthly_rate = (interest_rate.to_d / 100).div(12, DIVISION_PRECISION) - payment = exact_monthly_payment(principal, monthly_rate) + rate = monthly_rate + payment = exact_monthly_payment(principal, rate) start_date = schedule_start_date currency = account.currency @@ -94,7 +85,7 @@ def build_amortization_schedule rows = [] (1..term_months).each do |period| - interest_amount = (balance * monthly_rate).round(2) + 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. @@ -133,6 +124,10 @@ def exact_monthly_payment(principal, monthly_rate) 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 diff --git a/test/models/loan_test.rb b/test/models/loan_test.rb index e0f660157..367d2d25f 100644 --- a/test/models/loan_test.rb +++ b/test/models/loan_test.rb @@ -11,7 +11,7 @@ class LoanTest < ActiveSupport::TestCase test "calculates correct monthly payment for fixed rate loan" do loan_account = build_loan_account(balance: 500000, interest_rate: 3.5, term_months: 360) - assert_equal 2245, loan_account.loan.monthly_payment.amount + 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