diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 858e54a9b..f0c41dc53 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -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 diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 96f3f7c17..8062a1ca4 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -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? diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb index beb8fe5ec..283c172c7 100644 --- a/app/controllers/trades_controller.rb +++ b/app/controllers/trades_controller.rb @@ -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 diff --git a/app/models/category.rb b/app/models/category.rb index 936e0ebb7..1a50db9ae 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,5 +1,6 @@ class Category < ApplicationRecord has_many :transactions, dependent: :nullify, class_name: "Transaction" + has_many :trades, dependent: :nullify has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" belongs_to :family @@ -110,7 +111,8 @@ class Category < ApplicationRecord [ "Loan Payments", "#e11d48", "credit-card", "expense" ], [ "Services", "#7c3aed", "briefcase", "expense" ], [ "Fees", "#6b7280", "receipt", "expense" ], - [ "Savings & Investments", "#059669", "piggy-bank", "expense" ] + [ "Savings & Investments", "#059669", "piggy-bank", "expense" ], + [ "Investment Contributions", "#0d9488", "trending-up", "expense" ] ] end end diff --git a/app/models/family.rb b/app/models/family.rb index f72089e92..9d61c858d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -69,6 +69,10 @@ class Family < ApplicationRecord @income_statement ||= IncomeStatement.new(self) end + def investment_statement + @investment_statement ||= InvestmentStatement.new(self) + end + def eu? country != "US" && country != "CA" end diff --git a/app/models/holding.rb b/app/models/holding.rb index d3c117b4e..0b7f602af 100644 --- a/app/models/holding.rb +++ b/app/models/holding.rb @@ -28,32 +28,14 @@ class Holding < ApplicationRecord end # Basic approximation of cost-basis + # Uses pre-computed cost_basis if available (set during materialization), + # otherwise falls back to calculating from trades def avg_cost - trades = account.trades - .with_entry - .joins(ActiveRecord::Base.sanitize_sql_array([ - "LEFT JOIN exchange_rates ON ( - exchange_rates.date = entries.date AND - exchange_rates.from_currency = trades.currency AND - exchange_rates.to_currency = ? - )", account.currency - ])) - .where(security_id: security.id) - .where("trades.qty > 0 AND entries.date <= ?", date) + # Use stored cost_basis if available (eliminates N+1 queries) + return Money.new(cost_basis, currency) if cost_basis.present? - total_cost, total_qty = trades.pick( - Arel.sql("SUM(trades.price * trades.qty * COALESCE(exchange_rates.rate, 1))"), - Arel.sql("SUM(trades.qty)") - ) - - weighted_avg = - if total_qty && total_qty > 0 - total_cost / total_qty - else - price - end - - Money.new(weighted_avg || price, currency) + # Fallback to calculation for holdings without pre-computed cost_basis + calculate_avg_cost end def trend @@ -100,4 +82,32 @@ class Holding < ApplicationRecord current: amount_money, previous: start_amount end + + def calculate_avg_cost + trades = account.trades + .with_entry + .joins(ActiveRecord::Base.sanitize_sql_array([ + "LEFT JOIN exchange_rates ON ( + exchange_rates.date = entries.date AND + exchange_rates.from_currency = trades.currency AND + exchange_rates.to_currency = ? + )", account.currency + ])) + .where(security_id: security.id) + .where("trades.qty > 0 AND entries.date <= ?", date) + + total_cost, total_qty = trades.pick( + Arel.sql("SUM(trades.price * trades.qty * COALESCE(exchange_rates.rate, 1))"), + Arel.sql("SUM(trades.qty)") + ) + + weighted_avg = + if total_qty && total_qty > 0 + total_cost / total_qty + else + price + end + + Money.new(weighted_avg || price, currency) + end end diff --git a/app/models/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb index 43f91f7a6..ce490acba 100644 --- a/app/models/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -3,6 +3,8 @@ class Holding::ForwardCalculator def initialize(account) @account = account + # Track cost basis per security: { security_id => { total_cost: BigDecimal, total_qty: BigDecimal } } + @cost_basis_tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } } end def calculate @@ -13,6 +15,7 @@ class Holding::ForwardCalculator account.start_date.upto(Date.current).each do |date| trades = portfolio_cache.get_trades(date: date) + update_cost_basis_tracker(trades) next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward) holdings += build_holdings(next_portfolio, date) current_portfolio = next_portfolio @@ -65,8 +68,36 @@ class Holding::ForwardCalculator qty: qty, price: price.price, currency: price.currency, - amount: qty * price.price + amount: qty * price.price, + cost_basis: cost_basis_for(security_id, price.currency) ) end.compact end + + # Updates cost basis tracker with buy trades (qty > 0) + # Uses weighted average cost method + def update_cost_basis_tracker(trade_entries) + trade_entries.each do |trade_entry| + trade = trade_entry.entryable + next unless trade.qty > 0 # Only track buys + + security_id = trade.security_id + tracker = @cost_basis_tracker[security_id] + + # Convert trade price to account currency if needed + trade_price = Money.new(trade.price, trade.currency) + converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount + + tracker[:total_cost] += converted_price * trade.qty + tracker[:total_qty] += trade.qty + end + end + + # Returns the current cost basis for a security, or nil if no buys recorded + def cost_basis_for(security_id, currency) + tracker = @cost_basis_tracker[security_id] + return nil if tracker[:total_qty].zero? + + tracker[:total_cost] / tracker[:total_qty] + end end diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb index 6c1e8db19..fee335fd6 100644 --- a/app/models/holding/materializer.rb +++ b/app/models/holding/materializer.rb @@ -31,7 +31,7 @@ class Holding::Materializer account.holdings.upsert_all( @holdings.map { |h| h.attributes - .slice("date", "currency", "qty", "price", "amount", "security_id") + .slice("date", "currency", "qty", "price", "amount", "security_id", "cost_basis") .merge("account_id" => account.id, "updated_at" => current_time) }, unique_by: %i[account_id security_id date currency] ) diff --git a/app/models/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb index 656fc0d9b..2a4ea0375 100644 --- a/app/models/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -8,6 +8,7 @@ class Holding::ReverseCalculator def calculate Rails.logger.tagged("Holding::ReverseCalculator") do + precompute_cost_basis holdings = calculate_holdings Holding.gapfill(holdings) end @@ -69,8 +70,47 @@ class Holding::ReverseCalculator qty: qty, price: price.price, currency: price.currency, - amount: qty * price.price + amount: qty * price.price, + cost_basis: cost_basis_for(security_id, date) ) end.compact end + + # Pre-compute cost basis for all securities at all dates using forward pass through trades + # Stores: { security_id => { date => cost_basis } } + def precompute_cost_basis + @cost_basis_by_date = Hash.new { |h, k| h[k] = {} } + tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } } + + trades = portfolio_cache.get_trades.sort_by(&:date) + trade_index = 0 + + account.start_date.upto(Date.current).each do |date| + # Process all trades up to and including this date + while trade_index < trades.size && trades[trade_index].date <= date + trade_entry = trades[trade_index] + trade = trade_entry.entryable + + if trade.qty > 0 # Only track buys + security_id = trade.security_id + trade_price = Money.new(trade.price, trade.currency) + converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount + + tracker[security_id][:total_cost] += converted_price * trade.qty + tracker[security_id][:total_qty] += trade.qty + end + trade_index += 1 + end + + # Store current cost basis snapshot for each security at this date + tracker.each do |security_id, data| + next if data[:total_qty].zero? + @cost_basis_by_date[security_id][date] = data[:total_cost] / data[:total_qty] + end + end + end + + def cost_basis_for(security_id, date) + @cost_basis_by_date.dig(security_id, date) + end end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index b30b352f0..17db4cdcb 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -11,10 +11,10 @@ class IncomeStatement @family = family end - def totals(transactions_scope: nil) + def totals(transactions_scope: nil, date_range:) transactions_scope ||= family.transactions.visible - result = totals_query(transactions_scope: transactions_scope) + result = totals_query(transactions_scope: transactions_scope, date_range: date_range) total_income = result.select { |t| t.classification == "income" }.sum(&:total) total_expense = result.select { |t| t.classification == "expense" }.sum(&:total) @@ -64,7 +64,7 @@ class IncomeStatement end def build_period_total(classification:, period:) - totals = totals_query(transactions_scope: family.transactions.visible.in_period(period)).select { |t| t.classification == classification } + totals = totals_query(transactions_scope: family.transactions.visible.in_period(period), date_range: period.date_range).select { |t| t.classification == classification } classification_total = totals.sum(&:total) uncategorized_category = family.categories.uncategorized @@ -114,12 +114,12 @@ class IncomeStatement ]) { CategoryStats.new(family, interval:).call } end - def totals_query(transactions_scope:) + def totals_query(transactions_scope:, date_range:) sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql) Rails.cache.fetch([ "income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version - ]) { Totals.new(family, transactions_scope: transactions_scope).call } + ]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range).call } end def monetizable_currency diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 858093a78..7d4726b3a 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -1,7 +1,11 @@ class IncomeStatement::Totals - def initialize(family, transactions_scope:) + def initialize(family, transactions_scope:, date_range:, include_trades: true) @family = family @transactions_scope = transactions_scope + @date_range = date_range + @include_trades = include_trades + + validate_date_range! end def call @@ -21,14 +25,31 @@ class IncomeStatement::Totals def query_sql ActiveRecord::Base.sanitize_sql_array([ - optimized_query_sql, + @include_trades ? combined_query_sql : transactions_only_query_sql, sql_params ]) end - # OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing - # Eliminates CTE and intermediate date grouping for maximum performance - def optimized_query_sql + # Combined query that includes both transactions and trades + def combined_query_sql + <<~SQL + SELECT + category_id, + parent_category_id, + classification, + SUM(total) as total, + SUM(entry_count) as transactions_count + FROM ( + #{transactions_subquery_sql} + UNION ALL + #{trades_subquery_sql} + ) combined + GROUP BY category_id, parent_category_id, classification; + SQL + end + + # Original transactions-only query (for backwards compatibility) + def transactions_only_query_sql <<~SQL SELECT c.id as category_id, @@ -38,6 +59,7 @@ class IncomeStatement::Totals COUNT(ae.id) as transactions_count FROM (#{@transactions_scope.to_sql}) at JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' + JOIN accounts a ON a.id = ae.account_id LEFT JOIN categories c ON c.id = at.category_id LEFT JOIN exchange_rates er ON ( er.date = ae.date AND @@ -46,13 +68,81 @@ class IncomeStatement::Totals ) WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') AND ae.excluded = false + AND a.family_id = :family_id + AND a.status IN ('draft', 'active') GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; SQL end + def transactions_subquery_sql + <<~SQL + SELECT + c.id as category_id, + c.parent_id as parent_category_id, + CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total, + COUNT(ae.id) as entry_count + FROM (#{@transactions_scope.to_sql}) at + JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN categories c ON c.id = at.category_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') + AND ae.excluded = false + AND a.family_id = :family_id + AND a.status IN ('draft', 'active') + GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + SQL + end + + def trades_subquery_sql + # Get trades for the same family and date range as transactions + # Only include trades that have a category assigned + <<~SQL + SELECT + c.id as category_id, + c.parent_id as parent_category_id, + CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total, + COUNT(ae.id) as entry_count + FROM trades t + JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN categories c ON c.id = t.category_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE a.family_id = :family_id + AND a.status IN ('draft', 'active') + AND ae.excluded = false + AND ae.date BETWEEN :start_date AND :end_date + AND t.category_id IS NOT NULL + GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + SQL + end + def sql_params { - target_currency: @family.currency + target_currency: @family.currency, + family_id: @family.id, + start_date: @date_range.begin, + end_date: @date_range.end } end + + def validate_date_range! + unless @date_range.is_a?(Range) + raise ArgumentError, "date_range must be a Range, got #{@date_range.class}" + end + + unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date) + raise ArgumentError, "date_range must contain date-like objects" + end + end end diff --git a/app/models/investment_statement.rb b/app/models/investment_statement.rb new file mode 100644 index 000000000..ee8daacfa --- /dev/null +++ b/app/models/investment_statement.rb @@ -0,0 +1,191 @@ +require "digest/md5" + +class InvestmentStatement + include Monetizable + + monetize :total_contributions, :total_dividends, :total_interest, :unrealized_gains + + attr_reader :family + + def initialize(family) + @family = family + end + + # Get totals for a specific period + def totals(period: Period.current_month) + trades_in_period = family.trades + .joins(:entry) + .where(entries: { date: period.date_range }) + + result = totals_query(trades_scope: trades_in_period) + + PeriodTotals.new( + contributions: Money.new(result[:contributions], family.currency), + withdrawals: Money.new(result[:withdrawals], family.currency), + dividends: Money.new(result[:dividends], family.currency), + interest: Money.new(result[:interest], family.currency), + trades_count: result[:trades_count], + currency: family.currency + ) + end + + # Net contributions (contributions - withdrawals) + def net_contributions(period: Period.current_month) + t = totals(period: period) + t.contributions - t.withdrawals + end + + # Total portfolio value across all investment accounts + def portfolio_value + investment_accounts.sum(&:balance) + end + + def portfolio_value_money + Money.new(portfolio_value, family.currency) + end + + # Total cash in investment accounts + def cash_balance + investment_accounts.sum(&:cash_balance) + end + + def cash_balance_money + Money.new(cash_balance, family.currency) + end + + # Total holdings value + def holdings_value + portfolio_value - cash_balance + end + + def holdings_value_money + Money.new(holdings_value, family.currency) + end + + # All current holdings across investment accounts + def current_holdings + return Holding.none unless investment_accounts.any? + + account_ids = investment_accounts.pluck(:id) + + # Get the latest holding for each security per account + Holding + .where(account_id: account_ids) + .where(currency: family.currency) + .where.not(qty: 0) + .where( + id: Holding + .where(account_id: account_ids) + .where(currency: family.currency) + .select("DISTINCT ON (holdings.account_id, holdings.security_id) holdings.id") + .order(Arel.sql("holdings.account_id, holdings.security_id, holdings.date DESC")) + ) + .includes(:security, :account) + .order(amount: :desc) + end + + # Top holdings by value + def top_holdings(limit: 5) + current_holdings.limit(limit) + end + + # Portfolio allocation by security type/sector (simplified for now) + def allocation + holdings = current_holdings.to_a + total = holdings.sum(&:amount) + + return [] if total.zero? + + holdings.map do |holding| + HoldingAllocation.new( + security: holding.security, + amount: holding.amount_money, + weight: (holding.amount / total * 100).round(2), + trend: holding.trend + ) + end + end + + # Unrealized gains across all holdings + def unrealized_gains + current_holdings.sum do |holding| + trend = holding.trend + trend ? trend.value : 0 + end + end + + # Total contributions (all time) - returns numeric for monetize + def total_contributions + all_time_totals.contributions&.amount || 0 + end + + # Total dividends (all time) - returns numeric for monetize + def total_dividends + all_time_totals.dividends&.amount || 0 + end + + # Total interest (all time) - returns numeric for monetize + def total_interest + all_time_totals.interest&.amount || 0 + end + + def unrealized_gains_trend + holdings = current_holdings.to_a + return nil if holdings.empty? + + current = holdings.sum(&:amount) + previous = holdings.sum { |h| h.qty * h.avg_cost.amount } + + Trend.new(current: current, previous: previous) + end + + # Day change across portfolio + def day_change + holdings = current_holdings.to_a + changes = holdings.map(&:day_change).compact + + return nil if changes.empty? + + current = changes.sum { |t| t.current.is_a?(Money) ? t.current.amount : t.current } + previous = changes.sum { |t| t.previous.is_a?(Money) ? t.previous.amount : t.previous } + + Trend.new( + current: Money.new(current, family.currency), + previous: Money.new(previous, family.currency) + ) + end + + # Investment accounts + def investment_accounts + @investment_accounts ||= family.accounts.visible.where(accountable_type: %w[Investment Crypto]) + end + + private + def all_time_totals + @all_time_totals ||= totals(period: Period.all_time) + end + + PeriodTotals = Data.define(:contributions, :withdrawals, :dividends, :interest, :trades_count, :currency) do + def net_flow + contributions - withdrawals + end + + def total_income + dividends + interest + end + end + + HoldingAllocation = Data.define(:security, :amount, :weight, :trend) + + def totals_query(trades_scope:) + sql_hash = Digest::MD5.hexdigest(trades_scope.to_sql) + + Rails.cache.fetch([ + "investment_statement", "totals_query", family.id, sql_hash, family.entries_cache_version + ]) { Totals.new(family, trades_scope: trades_scope).call } + end + + def monetizable_currency + family.currency + end +end diff --git a/app/models/investment_statement/totals.rb b/app/models/investment_statement/totals.rb new file mode 100644 index 000000000..65af7be6f --- /dev/null +++ b/app/models/investment_statement/totals.rb @@ -0,0 +1,56 @@ +class InvestmentStatement::Totals + def initialize(family, trades_scope:) + @family = family + @trades_scope = trades_scope + end + + def call + result = ActiveRecord::Base.connection.select_one(query_sql) + + { + contributions: result["contributions"]&.to_d || 0, + withdrawals: result["withdrawals"]&.to_d || 0, + dividends: 0, # Dividends come through as transactions, not trades + interest: 0, # Interest comes through as transactions, not trades + trades_count: result["trades_count"]&.to_i || 0 + } + end + + private + def query_sql + ActiveRecord::Base.sanitize_sql_array([ + aggregation_sql, + sql_params + ]) + end + + # Aggregate trades by direction (buy vs sell) + # Buys (qty > 0) = contributions (cash going out to buy securities) + # Sells (qty < 0) = withdrawals (cash coming in from selling securities) + def aggregation_sql + <<~SQL + SELECT + COALESCE(SUM(CASE WHEN t.qty > 0 THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as contributions, + COALESCE(SUM(CASE WHEN t.qty < 0 THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as withdrawals, + COUNT(t.id) as trades_count + FROM (#{@trades_scope.to_sql}) t + JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE a.family_id = :family_id + AND a.status IN ('draft', 'active') + AND ae.excluded = false + SQL + end + + def sql_params + { + family_id: @family.id, + target_currency: @family.currency + } + end +end diff --git a/app/models/trade.rb b/app/models/trade.rb index f7be46827..b9233f9db 100644 --- a/app/models/trade.rb +++ b/app/models/trade.rb @@ -4,10 +4,20 @@ class Trade < ApplicationRecord monetize :price belongs_to :security + belongs_to :category, optional: true validates :qty, presence: true validates :price, :currency, presence: true + # Trade types for categorization + def buy? + qty.positive? + end + + def sell? + qty.negative? + end + class << self def build_name(type, qty, ticker) prefix = type == "buy" ? "Buy" : "Sell" diff --git a/app/models/trade/create_form.rb b/app/models/trade/create_form.rb index b07cc87d8..a6973df72 100644 --- a/app/models/trade/create_form.rb +++ b/app/models/trade/create_form.rb @@ -41,7 +41,8 @@ class Trade::CreateForm qty: signed_qty, price: price, currency: currency, - security: security + security: security, + category: investment_category_for(type) ) ) @@ -53,6 +54,14 @@ class Trade::CreateForm trade_entry end + def investment_category_for(trade_type) + # Buy trades are categorized as "Savings & Investments" (expense) + # Sell trades are left uncategorized for now + return nil unless trade_type == "buy" + + account.family.categories.find_by(name: "Savings & Investments") + end + def create_interest_income signed_amount = amount.to_d * -1 diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index 67fac05dc..99e7eb205 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -21,6 +21,7 @@ class TradeImport < Import qty: row.qty, currency: row.currency.presence || mapped_account.currency, price: row.price, + category: investment_category_for(row.qty, mapped_account.family), entry: Entry.new( account: mapped_account, date: row.date_iso, @@ -76,6 +77,14 @@ class TradeImport < Import end private + def investment_category_for(qty, family) + # Buy trades (positive qty) are categorized as "Savings & Investments" + # Sell trades are left uncategorized - users will be prompted to categorize + return nil unless qty.to_d.positive? + + family.categories.find_by(name: "Savings & Investments") + end + def find_or_create_security(ticker: nil, exchange_operating_mic: nil) return nil unless ticker.present? diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index aaf94f683..daf6454b4 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -46,6 +46,9 @@ data-action=" dragstart->dashboard-sortable#dragStart dragend->dashboard-sortable#dragEnd + touchstart->dashboard-sortable#touchStart + touchmove->dashboard-sortable#touchMove + touchend->dashboard-sortable#touchEnd keydown->dashboard-sortable#handleKeyDown">
@@ -64,12 +67,7 @@
diff --git a/app/views/pages/dashboard/_investment_summary.html.erb b/app/views/pages/dashboard/_investment_summary.html.erb new file mode 100644 index 000000000..e0db4e926 --- /dev/null +++ b/app/views/pages/dashboard/_investment_summary.html.erb @@ -0,0 +1,100 @@ +<%# locals: (investment_statement:, period:, **args) %> + +<% if investment_statement.investment_accounts.any? %> +
+
+
+
+

<%= t(".title") %>

+
+ +

+ <%= format_money(investment_statement.portfolio_value_money) %> +

+ + <% trend = investment_statement.unrealized_gains_trend %> + <% if trend %> +
+ <%= t(".total_return") %>: + + <%= format_money(Money.new(trend.value, Current.family.currency)) %> + (<%= trend.percent_formatted %>) + +
+ <% end %> +
+
+ + <% holdings = investment_statement.top_holdings(limit: 5) %> + <% if holdings.any? %> +
+
+
<%= t(".holding") %>
+
<%= t(".weight") %>
+
<%= t(".value") %>
+
<%= t(".return") %>
+
+ +
+ <% holdings.each_with_index do |holding, idx| %> +
+
+ <% if holding.security.logo_url.present? %> + <%= holding.ticker %> + <% else %> +
+ <%= holding.ticker[0..1] %> +
+ <% end %> +
+

<%= holding.ticker %>

+

<%= truncate(holding.name, length: 20) %>

+
+
+ +
+ <%= number_to_percentage(holding.weight || 0, precision: 1) %> +
+ +
+ <%= format_money(holding.amount_money) %> +
+ +
+ <% if holding.trend %> + + <%= holding.trend.percent_formatted %> + + <% else %> + - + <% end %> +
+
+ <% end %> +
+
+ <% end %> + + <%# Investment Activity Summary %> + <% totals = investment_statement.totals(period: period) %> + <% if totals.trades_count > 0 %> +
+

<%= t(".period_activity", period: period.label) %>

+
+
+ <%= t(".contributions") %>: + <%= format_money(totals.contributions) %> +
+
+ <%= t(".withdrawals") %>: + <%= format_money(totals.withdrawals) %> +
+
+ <%= t(".trades") %>: + <%= totals.trades_count %> +
+
+
+ <% end %> +
+<% end %> diff --git a/app/views/reports/_investment_performance.html.erb b/app/views/reports/_investment_performance.html.erb new file mode 100644 index 000000000..f88fa9fd2 --- /dev/null +++ b/app/views/reports/_investment_performance.html.erb @@ -0,0 +1,137 @@ +<%# locals: (investment_metrics:) %> + +<% if investment_metrics[:has_investments] %> +
+

<%= t("reports.investment_performance.title") %>

+ + <%# Investment Summary Cards %> +
+ <%# Portfolio Value Card %> +
+
+ <%= icon("briefcase", class: "w-4 h-4 text-secondary") %> + <%= t("reports.investment_performance.portfolio_value") %> +
+

+ <%= format_money(investment_metrics[:portfolio_value]) %> +

+
+ + <%# Total Return Card %> +
+
+ <%= icon("trending-up", class: "w-4 h-4 text-secondary") %> + <%= t("reports.investment_performance.total_return") %> +
+ <% if investment_metrics[:unrealized_trend] %> +

+ <%= format_money(Money.new(investment_metrics[:unrealized_trend].value, Current.family.currency)) %> + (<%= investment_metrics[:unrealized_trend].percent_formatted %>) +

+ <% else %> +

-

+ <% end %> +
+ + <%# Period Contributions Card %> +
+
+ <%= icon("arrow-down-to-line", class: "w-4 h-4 text-secondary") %> + <%= t("reports.investment_performance.contributions") %> +
+

+ <%= format_money(investment_metrics[:period_contributions]) %> +

+
+ + <%# Period Withdrawals Card %> +
+
+ <%= icon("arrow-up-from-line", class: "w-4 h-4 text-secondary") %> + <%= t("reports.investment_performance.withdrawals") %> +
+

+ <%= format_money(investment_metrics[:period_withdrawals]) %> +

+
+
+ + <%# Top Holdings Table %> + <% if investment_metrics[:top_holdings].any? %> +
+

<%= t("reports.investment_performance.top_holdings") %>

+ +
+ + + + + + + + + + + <% investment_metrics[:top_holdings].each do |holding| %> + + + + + + + <% end %> + +
<%= t("reports.investment_performance.holding") %><%= t("reports.investment_performance.weight") %><%= t("reports.investment_performance.value") %><%= t("reports.investment_performance.return") %>
+
+ <% if holding.security.logo_url.present? %> + <%= holding.ticker %> + <% else %> +
+ <%= holding.ticker[0..1] %> +
+ <% end %> +
+

<%= holding.ticker %>

+

<%= truncate(holding.name, length: 25) %>

+
+
+
+ <%= number_to_percentage(holding.weight || 0, precision: 1) %> + + <%= format_money(holding.amount_money) %> + + <% if holding.trend %> + + <%= holding.trend.percent_formatted %> + + <% else %> + - + <% end %> +
+
+
+ <% end %> + + <%# Investment Accounts Summary %> + <% if investment_metrics[:accounts].any? %> +
+

<%= t("reports.investment_performance.accounts") %>

+ +
+ <% investment_metrics[:accounts].each do |account| %> +
+
+ <%= render "accounts/logo", account: account, size: "sm" %> +
+

<%= account.name %>

+

<%= account.short_subtype_label %>

+
+
+

<%= format_money(account.balance_money) %>

+
+ <% end %> +
+
+ <% end %> +
+<% end %> diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index 5217bd8a0..032547cf5 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -211,6 +211,9 @@ data-action=" dragstart->reports-sortable#dragStart dragend->reports-sortable#dragEnd + touchstart->reports-sortable#touchStart + touchmove->reports-sortable#touchMove + touchend->reports-sortable#touchEnd keydown->reports-sortable#handleKeyDown">
@@ -229,12 +232,7 @@
diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index 02245edf6..ff5ab18cb 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -30,7 +30,7 @@
- <%= render "categories/badge", category: trade_category %> + <%= render "categories/badge", category: trade.category || trade_category %>
diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index 227470fba..9d5cf3d03 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -43,6 +43,11 @@ step: "any", precision: 10, disabled: @entry.linked? %> + + <%= ef.select :category_id, + Current.family.categories.expenses.alphabetically.map { |c| [c.name, c.id] }, + { include_blank: t(".no_category"), label: t(".category_label") }, + { data: { "auto-submit-form-target": "auto" } } %> <% end %> <% end %>
diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index 8fa38a416..f19322678 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -35,3 +35,16 @@ en: categories: "Categories" value: "Value" weight: "Weight" + investment_summary: + title: "Investments" + total_return: "Total Return" + holding: "Holding" + weight: "Weight" + value: "Value" + return: "Return" + period_activity: "%{period} Activity" + contributions: "Contributions" + withdrawals: "Withdrawals" + trades: "Trades" + no_investments: "No investment accounts" + add_investment: "Add an investment account to track your portfolio" diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml index 5b4ed7a49..6767fabd2 100644 --- a/config/locales/views/reports/en.yml +++ b/config/locales/views/reports/en.yml @@ -108,6 +108,18 @@ en: showing: Showing %{count} transactions previous: Previous next: Next + investment_performance: + title: Investment Performance + portfolio_value: Portfolio Value + total_return: Total Return + contributions: Period Contributions + withdrawals: Period Withdrawals + top_holdings: Top Holdings + holding: Holding + weight: Weight + value: Value + return: Return + accounts: Investment Accounts google_sheets_instructions: title_with_key: "✅ Copy URL for Google Sheets" title_no_key: "⚠️ API Key Required" diff --git a/config/locales/views/trades/en.yml b/config/locales/views/trades/en.yml index 959558653..514351455 100644 --- a/config/locales/views/trades/en.yml +++ b/config/locales/views/trades/en.yml @@ -24,6 +24,7 @@ en: title: New transaction show: additional: Additional + category_label: Category cost_per_share_label: Cost per Share date_label: Date delete: Delete @@ -32,6 +33,7 @@ en: details: Details exclude_subtitle: This trade will not be included in reports and calculations exclude_title: Exclude from analytics + no_category: No category note_label: Note note_placeholder: Add any additional notes here... quantity_label: Quantity diff --git a/db/migrate/20251125141213_add_category_to_trades.rb b/db/migrate/20251125141213_add_category_to_trades.rb new file mode 100644 index 000000000..01ecb82fd --- /dev/null +++ b/db/migrate/20251125141213_add_category_to_trades.rb @@ -0,0 +1,7 @@ +class AddCategoryToTrades < ActiveRecord::Migration[7.2] + def change + unless column_exists?(:trades, :category_id) + add_reference :trades, :category, null: true, foreign_key: true, type: :uuid + end + end +end diff --git a/test/controllers/categories_controller_test.rb b/test/controllers/categories_controller_test.rb index 5518ea731..2f7c8ff5e 100644 --- a/test/controllers/categories_controller_test.rb +++ b/test/controllers/categories_controller_test.rb @@ -84,7 +84,8 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest end test "bootstrap" do - assert_difference "Category.count", 19 do + # 22 default categories minus 2 that already exist in fixtures (Income, Food & Drink) + assert_difference "Category.count", 20 do post bootstrap_categories_url end diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index a2680ccb4..b152917c6 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -22,9 +22,10 @@ class IncomeStatementTest < ActiveSupport::TestCase test "calculates totals for transactions" do income_statement = IncomeStatement.new(@family) - assert_equal Money.new(1000, @family.currency), income_statement.totals.income_money - assert_equal Money.new(200 + 300 + 400, @family.currency), income_statement.totals.expense_money - assert_equal 4, income_statement.totals.transactions_count + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + assert_equal Money.new(1000, @family.currency), totals.income_money + assert_equal Money.new(200 + 300 + 400, @family.currency), totals.expense_money + assert_equal 4, totals.transactions_count end test "calculates expenses for a period" do @@ -157,7 +158,7 @@ class IncomeStatementTest < ActiveSupport::TestCase inflow_transaction = create_transaction(account: @credit_card_account, amount: -500, kind: "funds_movement") income_statement = IncomeStatement.new(@family) - totals = income_statement.totals + totals = income_statement.totals(date_range: Period.last_30_days.date_range) # NOW WORKING: Excludes transfers correctly after refactoring assert_equal 4, totals.transactions_count # Only original 4 transactions @@ -170,7 +171,7 @@ class IncomeStatementTest < ActiveSupport::TestCase loan_payment = create_transaction(account: @checking_account, amount: 1000, category: nil, kind: "loan_payment") income_statement = IncomeStatement.new(@family) - totals = income_statement.totals + totals = income_statement.totals(date_range: Period.last_30_days.date_range) # CONTINUES TO WORK: Includes loan payments as expenses (loan_payment not in exclusion list) assert_equal 5, totals.transactions_count @@ -183,7 +184,7 @@ class IncomeStatementTest < ActiveSupport::TestCase one_time_transaction = create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time") income_statement = IncomeStatement.new(@family) - totals = income_statement.totals + totals = income_statement.totals(date_range: Period.last_30_days.date_range) # NOW WORKING: Excludes one-time transactions correctly after refactoring assert_equal 4, totals.transactions_count # Only original 4 transactions @@ -196,7 +197,7 @@ class IncomeStatementTest < ActiveSupport::TestCase payment_transaction = create_transaction(account: @checking_account, amount: 300, category: nil, kind: "cc_payment") income_statement = IncomeStatement.new(@family) - totals = income_statement.totals + totals = income_statement.totals(date_range: Period.last_30_days.date_range) # NOW WORKING: Excludes payment transactions correctly after refactoring assert_equal 4, totals.transactions_count # Only original 4 transactions @@ -210,7 +211,7 @@ class IncomeStatementTest < ActiveSupport::TestCase excluded_transaction_entry.update!(excluded: true) income_statement = IncomeStatement.new(@family) - totals = income_statement.totals + totals = income_statement.totals(date_range: Period.last_30_days.date_range) # Should exclude excluded transactions assert_equal 4, totals.transactions_count # Only original 4 transactions @@ -278,7 +279,7 @@ class IncomeStatementTest < ActiveSupport::TestCase create_transaction(account: @checking_account, amount: 150, category: nil) income_statement = IncomeStatement.new(@family) - totals = income_statement.totals + totals = income_statement.totals(date_range: Period.last_30_days.date_range) # Should still include uncategorized transaction in totals assert_equal 5, totals.transactions_count diff --git a/test/models/trade_import_test.rb b/test/models/trade_import_test.rb index 54b307972..7914fe932 100644 --- a/test/models/trade_import_test.rb +++ b/test/models/trade_import_test.rb @@ -62,4 +62,54 @@ class TradeImportTest < ActiveSupport::TestCase assert_equal "complete", @import.status end + + test "auto-categorizes buy trades and leaves sell trades uncategorized" do + aapl = securities(:aapl) + aapl_resolver = mock + aapl_resolver.stubs(:resolve).returns(aapl) + Security::Resolver.stubs(:new).returns(aapl_resolver) + + # Create the investment category if it doesn't exist + account = accounts(:depository) + family = account.family + savings_category = family.categories.find_or_create_by!(name: "Savings & Investments") do |c| + c.color = "#059669" + c.classification = "expense" + c.lucide_icon = "piggy-bank" + end + + import = <<~CSV + date,ticker,qty,price,currency,name + 01/01/2024,AAPL,10,150.00,USD,Apple Buy + 01/02/2024,AAPL,-5,160.00,USD,Apple Sell + CSV + + @import.update!( + account: account, + raw_file_str: import, + date_col_label: "date", + ticker_col_label: "ticker", + qty_col_label: "qty", + price_col_label: "price", + date_format: "%m/%d/%Y", + signage_convention: "inflows_positive" + ) + + @import.generate_rows_from_csv + @import.reload + + assert_difference -> { Trade.count } => 2 do + @import.publish + end + + # Find trades created by this import + imported_trades = Trade.joins(:entry).where(entries: { import_id: @import.id }) + buy_trade = imported_trades.find { |t| t.qty.positive? } + sell_trade = imported_trades.find { |t| t.qty.negative? } + + assert_not_nil buy_trade, "Buy trade should have been created" + assert_not_nil sell_trade, "Sell trade should have been created" + assert_equal savings_category, buy_trade.category, "Buy trade should be auto-categorized as Savings & Investments" + assert_nil sell_trade.category, "Sell trade should not be auto-categorized" + end end