-
Notifications
You must be signed in to change notification settings - Fork 292
Feat/loans overview new insights #1775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
HugoleDino
wants to merge
56
commits into
we-promise:main
Choose a base branch
from
HugoleDino:feat/loans-overwiew-new-insights
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 13 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 3c11365
feat(overview): redesign view and add financial insights
HugoleDino 4e788f8
i18n: complete English and French translations
HugoleDino bc20238
chore(db): add Rails migrations
HugoleDino 6d2fadf
test: add and complete unit and system tests
HugoleDino e9e16c9
feat(loans-controller): add new loan fields to permitted attributes
HugoleDino 71dfb46
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino 37443bb
fix(loan): validate start_date
HugoleDino f7d7afd
fix(db): two-phase rollout
HugoleDino 24d63ce
i18n: french adjustable rate type translation
HugoleDino eba55f6
fix(loan_test): missing line after final end
HugoleDino 87778ab
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino fab6b5b
Merge branch 'main' into feat/loans-overwiew-new-insights
jjmata e1ab8aa
chore: GitHub workflow to auto-deploy PRs to Cloudflare (#880)
jjmata 831ca9b
fix(preview): use worker list metadata for cleanup (#1799)
sure-admin 3cb3f93
feat(i18n): extract hardcoded English strings to locale files (#1806)
brandonvers 9b09185
chore(ci): upgrade GitHub Actions to Node 24-compatible versions (#1810)
sure-admin bd48ab9
Update publish.yml
jjmata a2ab56a
Retry Enable Banking sync with provider-corrected date range (#1801)
sure-admin fddfaa4
Add `super_admin` debug event log (#1816)
sure-admin 041eba2
Skip to `alpha.9`
jjmata cf3c994
Add Actual Budget CSV import flow (#1830)
sure-admin a0a2d27
fix(pwa): serve manifest for html accept headers (#1828)
sure-admin fcc8320
feat: beta features toggle + Beta pill primitive (#1829)
gariasf 7c0fb61
feat(i18n): add Hungarian translations for strings extracted in #1806…
brandonvers f454b87
fix(ibkr): correct historical cash/non-cash split for linked accounts…
CrossDrain 47fa94a
Bump version by hand
jjmata 9a6cb5c
feat(i18n): complete Catalan translations + extract residual hardcode…
gariasf 20f9fa9
i18n(en): hardcode 'One error' in sso_providers errors_title singular…
gariasf 9fe8e8f
refactor: rename beta features gate to preview features (#1837)
gariasf 8a791d4
i18n: complete English and French translations
HugoleDino 0667419
chore(db): add Rails migrations
HugoleDino 3f605a8
fix(db): two-phase rollout
HugoleDino cffe16f
i18n: fix translation
HugoleDino 28b3684
fix CI failling test
HugoleDino fb96607
fix(db): migration version
HugoleDino 5128a47
fix(db): clean schema.rb
HugoleDino a6aac24
i18n: fix bug after merge
HugoleDino b130537
fix(db): fix bug after merge
HugoleDino a480263
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino d53777a
fix(migrations): handle missing index gracefully in RemoveObsoleteRec…
HugoleDino 2afcc40
fix(i18n): move styling to ERB and normalize label casing in loans lo…
HugoleDino 21e5786
fix: replace <p> with <div> in summary_card partial to fix invalid HTML
HugoleDino 0772a0a
fix(loans): use currentColor for SVG stroke with proper text utility …
HugoleDino 9ddd396
feat(loans): add leverage bands and leverage band calculation to Loan…
HugoleDino 4641870
fix(loans): rename variables for clarity in monthly_payment method
HugoleDino 3063eae
refactor(migration): remove french comment
HugoleDino a7343e0
fix(loans): remove font-semibold class from total cost and monthly pa…
HugoleDino 4bd3f07
feat(loans): update loan subtype translations and improve form labels
HugoleDino 81551d0
feat(loans): enhance loan account creation with detailed attributes f…
HugoleDino 5b36020
feat(loans): update loan overview to reflect months elapsed and remai…
HugoleDino e0bb8db
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino ef0a6b1
Merge branch 'main' into feat/loans-overwiew-new-insights
HugoleDino c6e1677
feat(loans): update color classes for paid percentage and progress in…
HugoleDino 7e0d9c1
fix(loans): theme repayment ring via shared donut-chart controller
gariasf 876cc2b
Merge branch 'main' into feat/loans-overwiew-new-insights
jjmata File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,26 +8,184 @@ class Loan < ApplicationRecord | |
| "other" => { short: "Other Loan", long: "Other Loan" } | ||
| }.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_cents = original_balance.amount | ||
| months = term_months | ||
| currency = account.currency | ||
|
|
||
| return Money.new(0, currency) if balance_cents.zero? || months.zero? | ||
|
|
||
| balance = BigDecimal(balance_cents) | ||
| 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 | ||
|
|
||
|
|
||
| def initial_leverage_ratio | ||
| dp = down_payment | ||
| return unless dp&.positive? | ||
|
|
||
| original_balance.to_f.fdiv(dp) | ||
| end | ||
|
|
||
| class << self | ||
|
|
@@ -43,4 +201,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| | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Add a maximum validation for AI prompt |
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.