Add investment tracking to expenses (#381)

* Add investment tracking to expenses

Add new sections to dashboard and reporting around investments.

* Create investment-integration-assessment.md

* Delete .claude/settings.local.json

Signed-off-by: soky srm <sokysrm@gmail.com>

* Category trades

* Simplify

* Simplification and test fixes

* FIX merge

* Update views

* Update 20251125141213_add_category_to_trades.rb

* FIX tests

* FIX statements and account status

* cleanup

* Add default cat for csv imports

* Delete docs/roadmap/investment-integration-assessment.md

Signed-off-by: soky srm <sokysrm@gmail.com>

* Update trend calculation

Use already existing column cost basis for trend calculation
   - Current value: qty * price (already stored as amount)
  - Cost basis total: qty * cost_basis
  - Unrealized gain: current value - cost basis total
Fixes N+1 query also

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
This commit is contained in:
soky srm
2026-01-09 13:03:40 +01:00
committed by GitHub
parent d185c6161c
commit 6ebe8da928
29 changed files with 907 additions and 131 deletions

View File

@@ -5,16 +5,17 @@ class PagesController < ApplicationController
def dashboard
@balance_sheet = Current.family.balance_sheet
@investment_statement = Current.family.investment_statement
@accounts = Current.family.accounts.visible.with_attached_logo
family_currency = Current.family.currency
# Use the same period for all widgets (set by Periodable concern)
# Use IncomeStatement for all cashflow data (now includes categorized trades)
income_totals = Current.family.income_statement.income_totals(period: @period)
expense_totals = Current.family.income_statement.expense_totals(period: @period)
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(expense_totals)
@dashboard_sections = build_dashboard_sections
@@ -81,6 +82,14 @@ class PagesController < ApplicationController
visible: Current.family.accounts.any? && @outflows_data[:categories].present?,
collapsible: true
},
{
key: "investment_summary",
title: "pages.dashboard.investment_summary.title",
partial: "pages/dashboard/investment_summary",
locals: { investment_statement: @investment_statement, period: @period },
visible: Current.family.accounts.any? && @investment_statement.investment_accounts.any?,
collapsible: true
},
{
key: "net_worth_chart",
title: "pages.dashboard.net_worth_chart.title",
@@ -117,12 +126,11 @@ class PagesController < ApplicationController
Provider::Registry.get_provider(:github)
end
def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)
def build_cashflow_sankey_data(income_totals, expense_totals, currency)
nodes = []
links = []
node_indices = {} # Memoize node indices by a unique key: "type_categoryid"
node_indices = {}
# Helper to add/find node and return its index
add_node = ->(unique_key, display_name, value, percentage, color) {
node_indices[unique_key] ||= begin
nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
@@ -130,93 +138,55 @@ class PagesController < ApplicationController
end
}
total_income_val = income_totals.total.to_f.round(2)
total_expense_val = expense_totals.total.to_f.round(2)
total_income = income_totals.total.to_f.round(2)
total_expense = expense_totals.total.to_f.round(2)
# --- Create Central Cash Flow Node ---
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)")
# Central Cash Flow node
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)")
# --- Process Income Side (Top-level categories only) ---
# Income side (top-level categories only)
income_totals.category_totals.each do |ct|
# Skip subcategories only include root income categories
next if ct.category.parent_id.present?
val = ct.total.to_f.round(2)
next if val.zero?
percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)
percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1)
color = ct.category.color.presence || Category::COLORS.sample
node_display_name = ct.category.name
node_color = ct.category.color.presence || Category::COLORS.sample
current_cat_idx = add_node.call(
"income_#{ct.category.id}",
node_display_name,
val,
percentage_of_total_income,
node_color
)
links << {
source: current_cat_idx,
target: cash_flow_idx,
value: val,
color: node_color,
percentage: percentage_of_total_income
}
idx = add_node.call("income_#{ct.category.id}", ct.category.name, val, percentage, color)
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
end
# --- Process Expense Side (Top-level categories only) ---
# Expense side (top-level categories only)
expense_totals.category_totals.each do |ct|
# Skip subcategories only include root expense categories to keep Sankey shallow
next if ct.category.parent_id.present?
val = ct.total.to_f.round(2)
next if val.zero?
percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)
percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1)
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
node_display_name = ct.category.name
node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
current_cat_idx = add_node.call(
"expense_#{ct.category.id}",
node_display_name,
val,
percentage_of_total_expense,
node_color
)
links << {
source: cash_flow_idx,
target: current_cat_idx,
value: val,
color: node_color,
percentage: percentage_of_total_expense
}
idx = add_node.call("expense_#{ct.category.id}", ct.category.name, val, percentage, color)
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
end
# --- Process Surplus ---
leftover = (total_income_val - total_expense_val).round(2)
if leftover.positive?
percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)
surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)")
links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus }
# Surplus/Deficit
net = (total_income - total_expense).round(2)
if net.positive?
percentage = total_income.zero? ? 0 : (net / total_income * 100).round(1)
idx = add_node.call("surplus_node", "Surplus", net, percentage, "var(--color-success)")
links << { source: cash_flow_idx, target: idx, value: net, color: "var(--color-success)", percentage: percentage }
end
# Update Cash Flow and Income node percentages (relative to total income)
if node_indices["cash_flow_node"]
nodes[node_indices["cash_flow_node"]][:percentage] = 100.0
end
# No primary income node anymore, percentages are on individual income cats relative to total_income_val
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol }
end
def build_outflows_donut_data(expense_totals, family_currency)
def build_outflows_donut_data(expense_totals)
currency_symbol = Money::Currency.new(expense_totals.currency).symbol
total = expense_totals.total
# Only include top-level categories with non-zero amounts
categories = expense_totals.category_totals
.reject { |ct| ct.category.parent_id.present? || ct.total.zero? }
.sort_by { |ct| -ct.total }
@@ -232,6 +202,6 @@ class PagesController < ApplicationController
}
end
{ categories: categories, total: total.to_f.round(2), currency: family_currency, currency_symbol: Money::Currency.new(family_currency).symbol }
{ categories: categories, total: total.to_f.round(2), currency: expense_totals.currency, currency_symbol: currency_symbol }
end
end

View File

@@ -37,6 +37,9 @@ class ReportsController < ApplicationController
# Transactions breakdown
@transactions = build_transactions_breakdown
# Investment metrics (must be before build_reports_sections)
@investment_metrics = build_investment_metrics
# Build reports sections for collapsible/reorderable UI
@reports_sections = build_reports_sections
@@ -129,6 +132,14 @@ class ReportsController < ApplicationController
visible: Current.family.transactions.any?,
collapsible: true
},
{
key: "investment_performance",
title: "reports.investment_performance.title",
partial: "reports/investment_performance",
locals: { investment_metrics: @investment_metrics },
visible: @investment_metrics[:has_investments],
collapsible: true
},
{
key: "transactions_breakdown",
title: "reports.transactions_breakdown.title",
@@ -408,6 +419,25 @@ class ReportsController < ApplicationController
end
end
def build_investment_metrics
investment_statement = Current.family.investment_statement
investment_accounts = investment_statement.investment_accounts
return { has_investments: false } unless investment_accounts.any?
period_totals = investment_statement.totals(period: @period)
{
has_investments: true,
portfolio_value: investment_statement.portfolio_value_money,
unrealized_trend: investment_statement.unrealized_gains_trend,
period_contributions: period_totals.contributions,
period_withdrawals: period_totals.withdrawals,
top_holdings: investment_statement.top_holdings(limit: 5),
accounts: investment_accounts.to_a
}
end
def apply_transaction_filters(transactions)
# Filter by category (including subcategories)
if params[:filter_category_id].present?

View File

@@ -54,7 +54,7 @@ class TradesController < ApplicationController
def entry_params
params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature,
entryable_attributes: [ :id, :qty, :price ]
entryable_attributes: [ :id, :qty, :price, :category_id ]
)
end