mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
191
app/models/investment_statement.rb
Normal file
191
app/models/investment_statement.rb
Normal file
@@ -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
|
||||
56
app/models/investment_statement/totals.rb
Normal file
56
app/models/investment_statement/totals.rb
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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">
|
||||
<div class="px-4 py-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -64,12 +67,7 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-1.5 -m-1 touch-none"
|
||||
data-dashboard-sortable-target="handle"
|
||||
data-action="
|
||||
touchstart->dashboard-sortable#touchStart
|
||||
touchmove->dashboard-sortable#touchMove
|
||||
touchend->dashboard-sortable#touchEnd"
|
||||
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100"
|
||||
aria-label="<%= t("pages.dashboard.drag_to_reorder") %>">
|
||||
<%= icon("grip-vertical", size: "sm") %>
|
||||
</button>
|
||||
|
||||
100
app/views/pages/dashboard/_investment_summary.html.erb
Normal file
100
app/views/pages/dashboard/_investment_summary.html.erb
Normal file
@@ -0,0 +1,100 @@
|
||||
<%# locals: (investment_statement:, period:, **args) %>
|
||||
|
||||
<% if investment_statement.investment_accounts.any? %>
|
||||
<div id="investment-summary" class="space-y-4">
|
||||
<div class="flex justify-between gap-4 px-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-medium"><%= t(".title") %></h2>
|
||||
</div>
|
||||
|
||||
<p class="text-primary text-3xl font-medium">
|
||||
<%= format_money(investment_statement.portfolio_value_money) %>
|
||||
</p>
|
||||
|
||||
<% trend = investment_statement.unrealized_gains_trend %>
|
||||
<% if trend %>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-secondary"><%= t(".total_return") %>:</span>
|
||||
<span class="font-medium" style="color: <%= trend.color %>">
|
||||
<%= format_money(Money.new(trend.value, Current.family.currency)) %>
|
||||
(<%= trend.percent_formatted %>)
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% holdings = investment_statement.top_holdings(limit: 5) %>
|
||||
<% if holdings.any? %>
|
||||
<div class="bg-container-inset rounded-xl p-1 mx-4">
|
||||
<div class="px-4 py-2 flex items-center uppercase text-xs font-medium text-secondary">
|
||||
<div class="flex-1"><%= t(".holding") %></div>
|
||||
<div class="w-20 text-right"><%= t(".weight") %></div>
|
||||
<div class="w-28 text-right"><%= t(".value") %></div>
|
||||
<div class="w-24 text-right"><%= t(".return") %></div>
|
||||
</div>
|
||||
|
||||
<div class="shadow-border-xs rounded-lg bg-container font-medium text-sm">
|
||||
<% holdings.each_with_index do |holding, idx| %>
|
||||
<div class="p-4 flex items-center <%= idx < holdings.size - 1 ? 'border-b border-primary' : '' %>">
|
||||
<div class="flex-1 flex items-center gap-3">
|
||||
<% if holding.security.logo_url.present? %>
|
||||
<img src="<%= holding.security.logo_url %>" alt="<%= holding.ticker %>" class="w-8 h-8 rounded-full">
|
||||
<% else %>
|
||||
<div class="w-8 h-8 rounded-full bg-container-inset flex items-center justify-center text-xs font-medium text-secondary">
|
||||
<%= holding.ticker[0..1] %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<p class="font-medium"><%= holding.ticker %></p>
|
||||
<p class="text-xs text-secondary"><%= truncate(holding.name, length: 20) %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-20 text-right text-secondary">
|
||||
<%= number_to_percentage(holding.weight || 0, precision: 1) %>
|
||||
</div>
|
||||
|
||||
<div class="w-28 text-right">
|
||||
<%= format_money(holding.amount_money) %>
|
||||
</div>
|
||||
|
||||
<div class="w-24 text-right">
|
||||
<% if holding.trend %>
|
||||
<span style="color: <%= holding.trend.color %>">
|
||||
<%= holding.trend.percent_formatted %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-secondary">-</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Investment Activity Summary %>
|
||||
<% totals = investment_statement.totals(period: period) %>
|
||||
<% if totals.trades_count > 0 %>
|
||||
<div class="px-4 pt-2">
|
||||
<p class="text-xs text-secondary uppercase font-medium mb-2"><%= t(".period_activity", period: period.label) %></p>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-secondary"><%= t(".contributions") %>:</span>
|
||||
<span class="font-medium text-primary"><%= format_money(totals.contributions) %></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-secondary"><%= t(".withdrawals") %>:</span>
|
||||
<span class="font-medium text-primary"><%= format_money(totals.withdrawals) %></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-secondary"><%= t(".trades") %>:</span>
|
||||
<span class="font-medium text-primary"><%= totals.trades_count %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
137
app/views/reports/_investment_performance.html.erb
Normal file
137
app/views/reports/_investment_performance.html.erb
Normal file
@@ -0,0 +1,137 @@
|
||||
<%# locals: (investment_metrics:) %>
|
||||
|
||||
<% if investment_metrics[:has_investments] %>
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-lg font-medium text-primary"><%= t("reports.investment_performance.title") %></h3>
|
||||
|
||||
<%# Investment Summary Cards %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<%# Portfolio Value Card %>
|
||||
<div class="bg-container-inset rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<%= icon("briefcase", class: "w-4 h-4 text-secondary") %>
|
||||
<span class="text-sm text-secondary"><%= t("reports.investment_performance.portfolio_value") %></span>
|
||||
</div>
|
||||
<p class="text-xl font-semibold text-primary">
|
||||
<%= format_money(investment_metrics[:portfolio_value]) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%# Total Return Card %>
|
||||
<div class="bg-container-inset rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<%= icon("trending-up", class: "w-4 h-4 text-secondary") %>
|
||||
<span class="text-sm text-secondary"><%= t("reports.investment_performance.total_return") %></span>
|
||||
</div>
|
||||
<% if investment_metrics[:unrealized_trend] %>
|
||||
<p class="text-xl font-semibold" style="color: <%= investment_metrics[:unrealized_trend].color %>">
|
||||
<%= format_money(Money.new(investment_metrics[:unrealized_trend].value, Current.family.currency)) %>
|
||||
(<%= investment_metrics[:unrealized_trend].percent_formatted %>)
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="text-xl font-semibold text-secondary">-</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Period Contributions Card %>
|
||||
<div class="bg-container-inset rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<%= icon("arrow-down-to-line", class: "w-4 h-4 text-secondary") %>
|
||||
<span class="text-sm text-secondary"><%= t("reports.investment_performance.contributions") %></span>
|
||||
</div>
|
||||
<p class="text-xl font-semibold text-primary">
|
||||
<%= format_money(investment_metrics[:period_contributions]) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%# Period Withdrawals Card %>
|
||||
<div class="bg-container-inset rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<%= icon("arrow-up-from-line", class: "w-4 h-4 text-secondary") %>
|
||||
<span class="text-sm text-secondary"><%= t("reports.investment_performance.withdrawals") %></span>
|
||||
</div>
|
||||
<p class="text-xl font-semibold text-primary">
|
||||
<%= format_money(investment_metrics[:period_withdrawals]) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Top Holdings Table %>
|
||||
<% if investment_metrics[:top_holdings].any? %>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium text-secondary uppercase"><%= t("reports.investment_performance.top_holdings") %></h4>
|
||||
|
||||
<div class="bg-container-inset rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-container">
|
||||
<tr class="text-left text-secondary uppercase text-xs">
|
||||
<th class="px-4 py-3 font-medium"><%= t("reports.investment_performance.holding") %></th>
|
||||
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.weight") %></th>
|
||||
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.value") %></th>
|
||||
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.return") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-primary">
|
||||
<% investment_metrics[:top_holdings].each do |holding| %>
|
||||
<tr>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<% if holding.security.logo_url.present? %>
|
||||
<img src="<%= holding.security.logo_url %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
|
||||
<% else %>
|
||||
<div class="w-6 h-6 rounded-full bg-container flex items-center justify-center text-xs font-medium text-secondary">
|
||||
<%= holding.ticker[0..1] %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= holding.ticker %></p>
|
||||
<p class="text-xs text-secondary"><%= truncate(holding.name, length: 25) %></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-secondary">
|
||||
<%= number_to_percentage(holding.weight || 0, precision: 1) %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium text-primary">
|
||||
<%= format_money(holding.amount_money) %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<% if holding.trend %>
|
||||
<span style="color: <%= holding.trend.color %>">
|
||||
<%= holding.trend.percent_formatted %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-secondary">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Investment Accounts Summary %>
|
||||
<% if investment_metrics[:accounts].any? %>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium text-secondary uppercase"><%= t("reports.investment_performance.accounts") %></h4>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<% investment_metrics[:accounts].each do |account| %>
|
||||
<div class="bg-container-inset rounded-lg p-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<%= render "accounts/logo", account: account, size: "sm" %>
|
||||
<div>
|
||||
<p class="font-medium text-primary text-sm"><%= account.name %></p>
|
||||
<p class="text-xs text-secondary"><%= account.short_subtype_label %></p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium text-primary"><%= format_money(account.balance_money) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -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">
|
||||
<div class="px-4 py-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -229,12 +232,7 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-1.5 -m-1 touch-none"
|
||||
data-reports-sortable-target="handle"
|
||||
data-action="
|
||||
touchstart->reports-sortable#touchStart
|
||||
touchmove->reports-sortable#touchMove
|
||||
touchend->reports-sortable#touchEnd"
|
||||
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100"
|
||||
aria-label="<%= t("reports.index.drag_to_reorder") %>">
|
||||
<%= icon("grip-vertical", size: "sm") %>
|
||||
</button>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex items-center">
|
||||
<%= render "categories/badge", category: trade_category %>
|
||||
<%= render "categories/badge", category: trade.category || trade_category %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end font-medium text-sm">
|
||||
|
||||
@@ -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 %>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
7
db/migrate/20251125141213_add_category_to_trades.rb
Normal file
7
db/migrate/20251125141213_add_category_to_trades.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user