Skip to content
Open
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
675f52b
feat(form): add down payment, insurance rate and start date inputs
HugoleDino May 12, 2026
3c11365
feat(overview): redesign view and add financial insights
HugoleDino May 12, 2026
4e788f8
i18n: complete English and French translations
HugoleDino May 12, 2026
bc20238
chore(db): add Rails migrations
HugoleDino May 12, 2026
6d2fadf
test: add and complete unit and system tests
HugoleDino May 12, 2026
e9e16c9
feat(loans-controller): add new loan fields to permitted attributes
HugoleDino May 12, 2026
71dfb46
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino May 12, 2026
37443bb
fix(loan): validate start_date
HugoleDino May 12, 2026
f7d7afd
fix(db): two-phase rollout
HugoleDino May 12, 2026
24d63ce
i18n: french adjustable rate type translation
HugoleDino May 12, 2026
eba55f6
fix(loan_test): missing line after final end
HugoleDino May 12, 2026
87778ab
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino May 12, 2026
fab6b5b
Merge branch 'main' into feat/loans-overwiew-new-insights
jjmata May 15, 2026
e1ab8aa
chore: GitHub workflow to auto-deploy PRs to Cloudflare (#880)
jjmata May 15, 2026
831ca9b
fix(preview): use worker list metadata for cleanup (#1799)
sure-admin May 16, 2026
3cb3f93
feat(i18n): extract hardcoded English strings to locale files (#1806)
brandonvers May 17, 2026
9b09185
chore(ci): upgrade GitHub Actions to Node 24-compatible versions (#1810)
sure-admin May 17, 2026
bd48ab9
Update publish.yml
jjmata May 17, 2026
a2ab56a
Retry Enable Banking sync with provider-corrected date range (#1801)
sure-admin May 17, 2026
fddfaa4
Add `super_admin` debug event log (#1816)
sure-admin May 17, 2026
041eba2
Skip to `alpha.9`
jjmata May 17, 2026
cf3c994
Add Actual Budget CSV import flow (#1830)
sure-admin May 18, 2026
a0a2d27
fix(pwa): serve manifest for html accept headers (#1828)
sure-admin May 18, 2026
fcc8320
feat: beta features toggle + Beta pill primitive (#1829)
gariasf May 18, 2026
7c0fb61
feat(i18n): add Hungarian translations for strings extracted in #1806…
brandonvers May 18, 2026
f454b87
fix(ibkr): correct historical cash/non-cash split for linked accounts…
CrossDrain May 18, 2026
47fa94a
Bump version by hand
jjmata May 18, 2026
9a6cb5c
feat(i18n): complete Catalan translations + extract residual hardcode…
gariasf May 19, 2026
20f9fa9
i18n(en): hardcode 'One error' in sso_providers errors_title singular…
gariasf May 19, 2026
9fe8e8f
refactor: rename beta features gate to preview features (#1837)
gariasf May 19, 2026
8a791d4
i18n: complete English and French translations
HugoleDino May 12, 2026
0667419
chore(db): add Rails migrations
HugoleDino May 12, 2026
3f605a8
fix(db): two-phase rollout
HugoleDino May 12, 2026
cffe16f
i18n: fix translation
HugoleDino May 19, 2026
28b3684
fix CI failling test
HugoleDino May 19, 2026
fb96607
fix(db): migration version
HugoleDino May 19, 2026
5128a47
fix(db): clean schema.rb
HugoleDino May 19, 2026
a6aac24
i18n: fix bug after merge
HugoleDino May 19, 2026
b130537
fix(db): fix bug after merge
HugoleDino May 19, 2026
a480263
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino May 19, 2026
d53777a
fix(migrations): handle missing index gracefully in RemoveObsoleteRec…
HugoleDino May 23, 2026
2afcc40
fix(i18n): move styling to ERB and normalize label casing in loans lo…
HugoleDino May 23, 2026
21e5786
fix: replace <p> with <div> in summary_card partial to fix invalid HTML
HugoleDino May 23, 2026
0772a0a
fix(loans): use currentColor for SVG stroke with proper text utility …
HugoleDino May 23, 2026
9ddd396
feat(loans): add leverage bands and leverage band calculation to Loan…
HugoleDino May 23, 2026
4641870
fix(loans): rename variables for clarity in monthly_payment method
HugoleDino May 23, 2026
3063eae
refactor(migration): remove french comment
HugoleDino May 23, 2026
a7343e0
fix(loans): remove font-semibold class from total cost and monthly pa…
HugoleDino May 24, 2026
4bd3f07
feat(loans): update loan subtype translations and improve form labels
HugoleDino May 24, 2026
81551d0
feat(loans): enhance loan account creation with detailed attributes f…
HugoleDino May 27, 2026
5b36020
feat(loans): update loan overview to reflect months elapsed and remai…
HugoleDino May 27, 2026
e0bb8db
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino May 27, 2026
ef0a6b1
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino May 28, 2026
c6e1677
feat(loans): update color classes for paid percentage and progress in…
HugoleDino May 28, 2026
7e0d9c1
fix(loans): theme repayment ring via shared donut-chart controller
gariasf May 30, 2026
876cc2b
Merge branch 'main' into feat/loans-overwiew-new-insights
jjmata Jun 14, 2026
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
2 changes: 1 addition & 1 deletion app/controllers/loans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ class LoansController < ApplicationController
include AccountableResource

permitted_accountable_attributes(
:id, :subtype, :rate_type, :interest_rate, :term_months, :initial_balance
:id, :subtype, :rate_type, :interest_rate, :insurance_rate_type, :insurance_rate, :term_months, :initial_balance, :down_payment, :start_date
)
end
42 changes: 39 additions & 3 deletions app/models/demo/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,45 @@ def create_realistic_accounts!(family)
@coinbase_usdc = family.accounts.create!(accountable: Crypto.new, name: "Coinbase USDC", balance: 0, currency: "USD")

# Loans / Liabilities (USD)
@mortgage = family.accounts.create!(accountable: Loan.new, name: "Home Mortgage", balance: 0, currency: "USD")
@car_loan = family.accounts.create!(accountable: Loan.new, name: "Car Loan", balance: 0, currency: "USD")
@student_loan = family.accounts.create!(accountable: Loan.new, name: "Student Loan", balance: 0, currency: "USD")
@mortgage = family.accounts.create!(
accountable: Loan.new(
subtype: "mortgage",
rate_type: "fixed",
interest_rate: 6.5,
term_months: 360,
start_date: 3.years.ago.to_date,
down_payment: 60_000,
insurance_rate: 0.5,
insurance_rate_type: "level_term",
initial_balance: 320_000
),
name: "Home Mortgage", balance: 0, currency: "USD"
)

@car_loan = family.accounts.create!(
accountable: Loan.new(
subtype: "auto",
rate_type: "fixed",
interest_rate: 7.0,
term_months: 60,
start_date: 1.year.ago.to_date,
down_payment: 5_000,
initial_balance: 25_000
),
name: "Car Loan", balance: 0, currency: "USD"
)

@student_loan = family.accounts.create!(
accountable: Loan.new(
subtype: "student",
rate_type: "fixed",
interest_rate: 5.0,
term_months: 120,
start_date: 2.years.ago.to_date,
initial_balance: 30_000
),
name: "Student Loan", balance: 0, currency: "USD"
)

@personal_loc = family.accounts.create!(accountable: OtherLiability.new, name: "Personal Line of Credit", balance: 0, currency: "USD")

Expand Down
268 changes: 257 additions & 11 deletions app/models/loan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,214 @@ class Loan < ApplicationRecord
"other" => { short: "Other Loan", long: "Other Loan" }
}.freeze

LEVERAGE_BANDS = {
conservative: 0..4,
moderate: 4..8,
high: 8..
}.freeze

before_validation :set_default_start_date, on: :create

validates :subtype, inclusion: { in: SUBTYPES.keys }, allow_blank: true

validates :interest_rate,
numericality: { greater_than_or_equal_to: 0 },
allow_nil: true

validates :insurance_rate,
numericality: { greater_than_or_equal_to: 0 },
allow_nil: true

validates :insurance_rate_type,
inclusion: { in: %w[level_term decreasing_life] },
allow_nil: true

validates :term_months,
numericality: { only_integer: true, greater_than: 0 },
allow_nil: true

validates :rate_type,
inclusion: { in: %w[fixed variable adjustable] },
allow_nil: true

validates :down_payment,
numericality: { greater_than_or_equal_to: 0 },
allow_nil: true

validates :start_date, presence: true




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

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 unless term_months && interest_rate && rate_type == "fixed"

annual_rate = interest_rate / 100.0
monthly_rate = annual_rate / 12.0
balance_amount = original_balance.amount
months = term_months
currency = account.currency

return Money.new(0, currency) if balance_amount.zero? || months.zero?

balance = BigDecimal(balance_amount)
rate = BigDecimal(interest_rate) / 1200

payment =
if rate.zero?
balance / months
else
factor = (1 + rate) ** months
balance * rate * factor / (factor - 1)
end

Money.new(payment.round(0).to_i, currency)
end

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)
def months_elapsed(as_of: Date.current)
start = start_date
term = term_months

return 0 unless start && term
return 0 if as_of < start

months = (as_of.year * 12 + as_of.month) - (start.year * 12 + start.month)
months -= 1 if start + months.months > as_of

months.clamp(0, term)
end


def remaining_months(as_of: Date.current)
term = term_months
return unless term

[ term - months_elapsed(as_of:), 0 ].max
end

def finished?(as_of: Date.current)
term = term_months
term && months_elapsed(as_of:) >= term
end

def amortization_schedule
return [] unless term_months && interest_rate && start_date && rate_type == "fixed"
Rails.cache.fetch([ "loan_amortization", cache_key_with_version, original_balance_cache_key ]) do
generate_amortization_schedule
end
end



Money.new(payment.round, account.currency)
def total_insurance
Money.new(amortization_schedule.sum { _1[:insurance] }, account.currency)
end

def original_balance
Money.new(account.first_valuation_amount, account.currency)
def total_paid
return unless (mp = monthly_payment) && term_months
mp * term_months
end

def total_interest
return unless (tp = total_paid) && (ob = original_balance)
tp - ob
end

def total_cost
tint = total_interest
ob = original_balance
tins = total_insurance

return nil unless tint && ob && tins

ob + tint + tins
end

def remaining_balance_at(month_number)
return unless month_number&.positive?
row = amortization_schedule[month_number - 1]
return unless row

Money.new(row[:remaining_balance].round, account.currency)
end

def payment_breakdown(month_number: nil)
month_number ||= months_elapsed(as_of: Date.current)
month_number = month_number.clamp(1, term_months || 1)

row = amortization_schedule[month_number - 1] or return

currency = account.currency
principal = Money.new(row[:principal], currency)
interest = Money.new(row[:interest], currency)
insurance = Money.new(row[:insurance], currency)
total = principal + interest + insurance

ratios =
if total.amount.positive?
total_f = total.to_f

{
principal: principal.to_f / total_f,
interest: interest.to_f / total_f,
insurance: insurance.to_f / total_f
}
else
{ principal: 0.0, interest: 0.0, insurance: 0.0 }
end
{
month: month_number,
date: row[:date],
principal: principal,
interest: interest,
insurance: insurance,
total: total,
ratios: ratios
}
end

def balance_paid_ratio
initial = initial_balance
return unless initial&.positive?

current = account.balance
return unless current

(1 - current.fdiv(initial)).clamp(0.0, 1.0)
end

# Donut-chart segments for the repayment-progress ring, consumed by the
# shared `donut-chart` Stimulus controller (the same one Budgets and Goals
# use). A warning-colored "paid" arc plus a `--budget-unused-fill` track
# segment — both CSS-var driven so the ring themes correctly in light/dark.
# The view marks "paid" as the controller's overage segment so its var()
# color is passed straight to the SVG fill instead of being parsed by
# d3.color() (which returns null for var(...)). Returns nil when paydown
# can't be computed, in which case the view hides the ring.
def to_donut_segments
ratio = balance_paid_ratio
return unless ratio

[
{ color: "var(--color-warning)", amount: ratio, id: "paid" },
{ color: "var(--budget-unused-fill)", amount: 1 - ratio, id: "unused" }
]
end

def initial_leverage_ratio
dp = down_payment
return unless dp&.positive?

original_balance.to_f.fdiv(dp)
end

def leverage_band
ratio = initial_leverage_ratio
return unless ratio

LEVERAGE_BANDS.find { |_, range| range.cover?(ratio) }&.first
end

class << self
Expand All @@ -43,4 +231,62 @@ def classification
"liability"
end
end

private

def original_balance_cache_key
account&.valuations&.first&.updated_at&.to_i
end

def set_default_start_date
self.start_date ||= Date.current
end

def payment_date_for(month_number)
start = start_date
return unless start && month_number&.positive?
start + (month_number - 1).months
end


def generate_amortization_schedule
return [] if interest_rate.nil? || term_months.nil? || start_date.nil?

balance = BigDecimal(original_balance.amount.to_s)
initial_balance = balance

rate = BigDecimal(interest_rate.to_s) / 1200
insurance_rate_m = BigDecimal((insurance_rate || 0).to_s) / 1200

return [] unless (monthly = monthly_payment)
payment = BigDecimal(monthly.amount.to_s)

schedule = []
months = term_months
date = start_date

months.times do |i|

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Unbounded term_months allows DoS via amortization schedule generation

generate_amortization_schedule loops term_months times with no upper bound.

Add a maximum validation for term_months to prevent DoS from huge schedules.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="app/models/loan.rb">
<violation number="1" location="app/models/loan.rb:271">
<priority>P1</priority>
<title>Unbounded term_months allows DoS via amortization schedule generation</title>
<evidence>The generate_amortization_schedule method performs `months.times do |i|` where `months` is `term_months`, but `term_months` only has a validation for `greater_than: 0` with no upper bound. An authenticated attacker can create a loan with an extremely large term_months value (e.g., 999999999) and viewing the loan overview will trigger a long-running loop that consumes CPU and memory, potentially causing a denial of service.</evidence>
<recommendation>Add a maximum value validation for `term_months` in the Loan model, such as `less_than_or_equal_to: 600` (50 years), to cap the amortization schedule. Additionally, add a guard clause in `generate_amortization_schedule` to short-circuit or raise when `term_months` exceeds the permitted maximum.</recommendation>
</violation>
</file>

interest = (balance * rate).round(0)

insurance_base =
insurance_rate_type == "level_term" ? initial_balance : nil
base = insurance_base || balance
insurance_amount = (base * insurance_rate_m).round(0)

principal = payment - interest
balance = [ balance - principal, 0 ].max

schedule << {
month: i + 1,
date: start_date >> i,
payment: payment.to_i,
interest: interest.to_i,
principal: principal.to_i,
insurance: insurance_amount.to_i,
remaining_balance: balance.round(0).to_i
}
end

schedule
end
end
4 changes: 2 additions & 2 deletions app/views/accounts/_summary_card.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<div class="rounded-xl bg-container shadow-xs border border-alpha-black-25 p-4">
<h4 class="text-secondary text-sm"><%= title %></h4>
<p class="text-xl font-medium text-primary privacy-sensitive">
<div class="text-xl font-medium text-primary privacy-sensitive">
<%= content %>
</p>
</div>
</div>
Loading
Loading