mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 19:44:09 +00:00
Support uncategorized investments (#593)
* Support uncategorized investments * FIX sankey id collision * Fix reports * Fix hardcoded string and i8n * FIX plurals * Remove spending patterns section add net worth section to reports
This commit is contained in:
@@ -154,7 +154,9 @@ class PagesController < ApplicationController
|
|||||||
percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1)
|
percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1)
|
||||||
color = ct.category.color.presence || Category::COLORS.sample
|
color = ct.category.color.presence || Category::COLORS.sample
|
||||||
|
|
||||||
idx = add_node.call("income_#{ct.category.id}", ct.category.name, val, percentage, color)
|
# Use name as fallback key for synthetic categories (no id)
|
||||||
|
node_key = "income_#{ct.category.id || ct.category.name}"
|
||||||
|
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
|
||||||
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
|
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -168,7 +170,9 @@ class PagesController < ApplicationController
|
|||||||
percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1)
|
percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1)
|
||||||
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
|
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
|
||||||
|
|
||||||
idx = add_node.call("expense_#{ct.category.id}", ct.category.name, val, percentage, color)
|
# Use name as fallback key for synthetic categories (no id)
|
||||||
|
node_key = "expense_#{ct.category.id || ct.category.name}"
|
||||||
|
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
|
||||||
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
|
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -198,7 +202,8 @@ class PagesController < ApplicationController
|
|||||||
currency: ct.currency,
|
currency: ct.currency,
|
||||||
percentage: ct.weight.round(1),
|
percentage: ct.weight.round(1),
|
||||||
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
|
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
|
||||||
icon: ct.category.lucide_icon
|
icon: ct.category.lucide_icon,
|
||||||
|
clickable: !ct.category.other_investments?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ class ReportsController < ApplicationController
|
|||||||
# Build trend data (last 6 months)
|
# Build trend data (last 6 months)
|
||||||
@trends_data = build_trends_data
|
@trends_data = build_trends_data
|
||||||
|
|
||||||
# Spending patterns (weekday vs weekend)
|
# Net worth metrics
|
||||||
@spending_patterns = build_spending_patterns
|
@net_worth_metrics = build_net_worth_metrics
|
||||||
|
|
||||||
# Transactions breakdown
|
# Transactions breakdown
|
||||||
@transactions = build_transactions_breakdown
|
@transactions = build_transactions_breakdown
|
||||||
@@ -124,11 +124,19 @@ class ReportsController < ApplicationController
|
|||||||
|
|
||||||
def build_reports_sections
|
def build_reports_sections
|
||||||
all_sections = [
|
all_sections = [
|
||||||
|
{
|
||||||
|
key: "net_worth",
|
||||||
|
title: "reports.net_worth.title",
|
||||||
|
partial: "reports/net_worth",
|
||||||
|
locals: { net_worth_metrics: @net_worth_metrics },
|
||||||
|
visible: Current.family.accounts.any?,
|
||||||
|
collapsible: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "trends_insights",
|
key: "trends_insights",
|
||||||
title: "reports.trends.title",
|
title: "reports.trends.title",
|
||||||
partial: "reports/trends_insights",
|
partial: "reports/trends_insights",
|
||||||
locals: { trends_data: @trends_data, spending_patterns: @spending_patterns },
|
locals: { trends_data: @trends_data },
|
||||||
visible: Current.family.transactions.any?,
|
visible: Current.family.transactions.any?,
|
||||||
collapsible: true
|
collapsible: true
|
||||||
},
|
},
|
||||||
@@ -310,61 +318,6 @@ class ReportsController < ApplicationController
|
|||||||
trends
|
trends
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_spending_patterns
|
|
||||||
# Analyze weekday vs weekend spending
|
|
||||||
weekday_total = 0
|
|
||||||
weekend_total = 0
|
|
||||||
weekday_count = 0
|
|
||||||
weekend_count = 0
|
|
||||||
|
|
||||||
# Build query matching income_statement logic:
|
|
||||||
# Expenses are transactions with positive amounts, regardless of category
|
|
||||||
expense_transactions = Transaction
|
|
||||||
.joins(:entry)
|
|
||||||
.joins(entry: :account)
|
|
||||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
|
||||||
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
|
|
||||||
.where(kind: [ "standard", "loan_payment" ])
|
|
||||||
.where("entries.amount > 0") # Positive amount = expense (matching income_statement logic)
|
|
||||||
|
|
||||||
# Sum up amounts by weekday vs weekend
|
|
||||||
expense_transactions.each do |transaction|
|
|
||||||
entry = transaction.entry
|
|
||||||
amount = entry.amount.abs
|
|
||||||
|
|
||||||
if entry.date.wday.in?([ 0, 6 ]) # Sunday or Saturday
|
|
||||||
weekend_total += amount
|
|
||||||
weekend_count += 1
|
|
||||||
else
|
|
||||||
weekday_total += amount
|
|
||||||
weekday_count += 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
weekday_avg = weekday_count.positive? ? (weekday_total / weekday_count) : 0
|
|
||||||
weekend_avg = weekend_count.positive? ? (weekend_total / weekend_count) : 0
|
|
||||||
|
|
||||||
{
|
|
||||||
weekday_total: weekday_total,
|
|
||||||
weekend_total: weekend_total,
|
|
||||||
weekday_avg: weekday_avg,
|
|
||||||
weekend_avg: weekend_avg,
|
|
||||||
weekday_count: weekday_count,
|
|
||||||
weekend_count: weekend_count
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_spending_patterns
|
|
||||||
{
|
|
||||||
weekday_total: 0,
|
|
||||||
weekend_total: 0,
|
|
||||||
weekday_avg: 0,
|
|
||||||
weekend_avg: 0,
|
|
||||||
weekday_count: 0,
|
|
||||||
weekend_count: 0
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_transactions_breakdown
|
def build_transactions_breakdown
|
||||||
# Base query: all transactions in the period
|
# Base query: all transactions in the period
|
||||||
# Exclude transfers, one-time, and CC payments (matching income_statement logic)
|
# Exclude transfers, one-time, and CC payments (matching income_statement logic)
|
||||||
@@ -379,25 +332,55 @@ class ReportsController < ApplicationController
|
|||||||
# Apply filters
|
# Apply filters
|
||||||
transactions = apply_transaction_filters(transactions)
|
transactions = apply_transaction_filters(transactions)
|
||||||
|
|
||||||
|
# Get trades in the period (matching income_statement logic)
|
||||||
|
trades = Trade
|
||||||
|
.joins(:entry)
|
||||||
|
.joins(entry: :account)
|
||||||
|
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||||
|
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
|
||||||
|
.includes(entry: :account, category: [])
|
||||||
|
|
||||||
# Get sort parameters
|
# Get sort parameters
|
||||||
sort_by = params[:sort_by] || "amount"
|
sort_by = params[:sort_by] || "amount"
|
||||||
sort_direction = params[:sort_direction] || "desc"
|
sort_direction = params[:sort_direction] || "desc"
|
||||||
|
|
||||||
# Group by category and type
|
# Group by category and type
|
||||||
all_transactions = transactions.to_a
|
|
||||||
grouped_data = {}
|
grouped_data = {}
|
||||||
|
family_currency = Current.family.currency
|
||||||
|
|
||||||
all_transactions.each do |transaction|
|
# Process transactions
|
||||||
|
transactions.each do |transaction|
|
||||||
entry = transaction.entry
|
entry = transaction.entry
|
||||||
is_expense = entry.amount > 0
|
is_expense = entry.amount > 0
|
||||||
type = is_expense ? "expense" : "income"
|
type = is_expense ? "expense" : "income"
|
||||||
category_name = transaction.category&.name || "Uncategorized"
|
category_name = transaction.category&.name || "Uncategorized"
|
||||||
category_color = transaction.category&.color || "#9CA3AF"
|
category_color = transaction.category&.color || Category::UNCATEGORIZED_COLOR
|
||||||
|
|
||||||
|
# Convert to family currency
|
||||||
|
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
|
||||||
|
|
||||||
key = [ category_name, type, category_color ]
|
key = [ category_name, type, category_color ]
|
||||||
grouped_data[key] ||= { total: 0, count: 0 }
|
grouped_data[key] ||= { total: 0, count: 0 }
|
||||||
grouped_data[key][:count] += 1
|
grouped_data[key][:count] += 1
|
||||||
grouped_data[key][:total] += entry.amount.abs
|
grouped_data[key][:total] += converted_amount
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process trades
|
||||||
|
trades.each do |trade|
|
||||||
|
entry = trade.entry
|
||||||
|
is_expense = entry.amount > 0
|
||||||
|
type = is_expense ? "expense" : "income"
|
||||||
|
# Use "Other Investments" for trades without category
|
||||||
|
category_name = trade.category&.name || Category.other_investments_name
|
||||||
|
category_color = trade.category&.color || Category::OTHER_INVESTMENTS_COLOR
|
||||||
|
|
||||||
|
# Convert to family currency
|
||||||
|
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
|
||||||
|
|
||||||
|
key = [ category_name, type, category_color ]
|
||||||
|
grouped_data[key] ||= { total: 0, count: 0 }
|
||||||
|
grouped_data[key][:count] += 1
|
||||||
|
grouped_data[key][:total] += converted_amount
|
||||||
end
|
end
|
||||||
|
|
||||||
# Convert to array
|
# Convert to array
|
||||||
@@ -438,6 +421,39 @@ class ReportsController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_net_worth_metrics
|
||||||
|
balance_sheet = Current.family.balance_sheet
|
||||||
|
currency = Current.family.currency
|
||||||
|
|
||||||
|
# Current net worth
|
||||||
|
current_net_worth = balance_sheet.net_worth
|
||||||
|
total_assets = balance_sheet.assets.total
|
||||||
|
total_liabilities = balance_sheet.liabilities.total
|
||||||
|
|
||||||
|
# Get net worth series for the period to calculate change
|
||||||
|
# The series.trend gives us the change from first to last value in the period
|
||||||
|
net_worth_series = balance_sheet.net_worth_series(period: @period)
|
||||||
|
trend = net_worth_series&.trend
|
||||||
|
|
||||||
|
# Get asset and liability groups for breakdown
|
||||||
|
asset_groups = balance_sheet.assets.account_groups.map do |group|
|
||||||
|
{ name: group.name, total: Money.new(group.total, currency) }
|
||||||
|
end.reject { |g| g[:total].zero? }
|
||||||
|
|
||||||
|
liability_groups = balance_sheet.liabilities.account_groups.map do |group|
|
||||||
|
{ name: group.name, total: Money.new(group.total, currency) }
|
||||||
|
end.reject { |g| g[:total].zero? }
|
||||||
|
|
||||||
|
{
|
||||||
|
current_net_worth: Money.new(current_net_worth, currency),
|
||||||
|
total_assets: Money.new(total_assets, currency),
|
||||||
|
total_liabilities: Money.new(total_liabilities, currency),
|
||||||
|
trend: trend,
|
||||||
|
asset_groups: asset_groups,
|
||||||
|
liability_groups: liability_groups
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def apply_transaction_filters(transactions)
|
def apply_transaction_filters(transactions)
|
||||||
# Filter by category (including subcategories)
|
# Filter by category (including subcategories)
|
||||||
if params[:filter_category_id].present?
|
if params[:filter_category_id].present?
|
||||||
@@ -533,9 +549,19 @@ class ReportsController < ApplicationController
|
|||||||
|
|
||||||
transactions = apply_transaction_filters(transactions)
|
transactions = apply_transaction_filters(transactions)
|
||||||
|
|
||||||
# Group transactions by category, type, and month
|
# Get trades in the period (matching income_statement logic)
|
||||||
breakdown = {}
|
trades = Trade
|
||||||
|
.joins(:entry)
|
||||||
|
.joins(entry: :account)
|
||||||
|
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||||
|
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
|
||||||
|
.includes(entry: :account, category: [])
|
||||||
|
|
||||||
|
# Group by category, type, and month
|
||||||
|
breakdown = {}
|
||||||
|
family_currency = Current.family.currency
|
||||||
|
|
||||||
|
# Process transactions
|
||||||
transactions.each do |transaction|
|
transactions.each do |transaction|
|
||||||
entry = transaction.entry
|
entry = transaction.entry
|
||||||
is_expense = entry.amount > 0
|
is_expense = entry.amount > 0
|
||||||
@@ -543,11 +569,33 @@ class ReportsController < ApplicationController
|
|||||||
category_name = transaction.category&.name || "Uncategorized"
|
category_name = transaction.category&.name || "Uncategorized"
|
||||||
month_key = entry.date.beginning_of_month
|
month_key = entry.date.beginning_of_month
|
||||||
|
|
||||||
|
# Convert to family currency
|
||||||
|
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
|
||||||
|
|
||||||
key = [ category_name, type ]
|
key = [ category_name, type ]
|
||||||
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
|
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
|
||||||
breakdown[key][:months][month_key] ||= 0
|
breakdown[key][:months][month_key] ||= 0
|
||||||
breakdown[key][:months][month_key] += entry.amount.abs
|
breakdown[key][:months][month_key] += converted_amount
|
||||||
breakdown[key][:total] += entry.amount.abs
|
breakdown[key][:total] += converted_amount
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process trades
|
||||||
|
trades.each do |trade|
|
||||||
|
entry = trade.entry
|
||||||
|
is_expense = entry.amount > 0
|
||||||
|
type = is_expense ? "expense" : "income"
|
||||||
|
# Use "Other Investments" for trades without category
|
||||||
|
category_name = trade.category&.name || Category.other_investments_name
|
||||||
|
month_key = entry.date.beginning_of_month
|
||||||
|
|
||||||
|
# Convert to family currency
|
||||||
|
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
|
||||||
|
|
||||||
|
key = [ category_name, type ]
|
||||||
|
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
|
||||||
|
breakdown[key][:months][month_key] ||= 0
|
||||||
|
breakdown[key][:months][month_key] += converted_amount
|
||||||
|
breakdown[key][:total] += converted_amount
|
||||||
end
|
end
|
||||||
|
|
||||||
# Convert to array and sort by type and total (descending)
|
# Convert to array and sort by type and total (descending)
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ class Category < ApplicationRecord
|
|||||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||||
|
|
||||||
UNCATEGORIZED_COLOR = "#737373"
|
UNCATEGORIZED_COLOR = "#737373"
|
||||||
|
OTHER_INVESTMENTS_COLOR = "#e99537"
|
||||||
TRANSFER_COLOR = "#444CE7"
|
TRANSFER_COLOR = "#444CE7"
|
||||||
PAYMENT_COLOR = "#db5a54"
|
PAYMENT_COLOR = "#db5a54"
|
||||||
TRADE_COLOR = "#e99537"
|
TRADE_COLOR = "#e99537"
|
||||||
|
|
||||||
|
# Synthetic category name keys for i18n
|
||||||
|
UNCATEGORIZED_NAME_KEY = "models.category.uncategorized"
|
||||||
|
OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments"
|
||||||
|
|
||||||
class Group
|
class Group
|
||||||
attr_reader :category, :subcategories
|
attr_reader :category, :subcategories
|
||||||
|
|
||||||
@@ -82,12 +87,30 @@ class Category < ApplicationRecord
|
|||||||
|
|
||||||
def uncategorized
|
def uncategorized
|
||||||
new(
|
new(
|
||||||
name: "Uncategorized",
|
name: I18n.t(UNCATEGORIZED_NAME_KEY),
|
||||||
color: UNCATEGORIZED_COLOR,
|
color: UNCATEGORIZED_COLOR,
|
||||||
lucide_icon: "circle-dashed"
|
lucide_icon: "circle-dashed"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def other_investments
|
||||||
|
new(
|
||||||
|
name: I18n.t(OTHER_INVESTMENTS_NAME_KEY),
|
||||||
|
color: OTHER_INVESTMENTS_COLOR,
|
||||||
|
lucide_icon: "trending-up"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to get the localized name for uncategorized
|
||||||
|
def uncategorized_name
|
||||||
|
I18n.t(UNCATEGORIZED_NAME_KEY)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to get the localized name for other investments
|
||||||
|
def other_investments_name
|
||||||
|
I18n.t(OTHER_INVESTMENTS_NAME_KEY)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def default_categories
|
def default_categories
|
||||||
[
|
[
|
||||||
@@ -142,6 +165,21 @@ class Category < ApplicationRecord
|
|||||||
subcategory? ? "#{parent.name} > #{name}" : name
|
subcategory? ? "#{parent.name} > #{name}" : name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Predicate: is this the synthetic "Uncategorized" category?
|
||||||
|
def uncategorized?
|
||||||
|
!persisted? && name == I18n.t(UNCATEGORIZED_NAME_KEY)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Predicate: is this the synthetic "Other Investments" category?
|
||||||
|
def other_investments?
|
||||||
|
!persisted? && name == I18n.t(OTHER_INVESTMENTS_NAME_KEY)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Predicate: is this any synthetic (non-persisted) category?
|
||||||
|
def synthetic?
|
||||||
|
uncategorized? || other_investments?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def category_level_limit
|
def category_level_limit
|
||||||
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)
|
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)
|
||||||
|
|||||||
@@ -68,13 +68,22 @@ class IncomeStatement
|
|||||||
classification_total = totals.sum(&:total)
|
classification_total = totals.sum(&:total)
|
||||||
|
|
||||||
uncategorized_category = family.categories.uncategorized
|
uncategorized_category = family.categories.uncategorized
|
||||||
|
other_investments_category = family.categories.other_investments
|
||||||
|
|
||||||
category_totals = [ *categories, uncategorized_category ].map do |category|
|
category_totals = [ *categories, uncategorized_category, other_investments_category ].map do |category|
|
||||||
subcategory = categories.find { |c| c.id == category.parent_id }
|
subcategory = categories.find { |c| c.id == category.parent_id }
|
||||||
|
|
||||||
parent_category_total = totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
|
parent_category_total = if category.uncategorized?
|
||||||
|
# Regular uncategorized: NULL category_id and NOT uncategorized investment
|
||||||
|
totals.select { |t| t.category_id.nil? && !t.is_uncategorized_investment }&.sum(&:total) || 0
|
||||||
|
elsif category.other_investments?
|
||||||
|
# Other investments: NULL category_id AND is_uncategorized_investment
|
||||||
|
totals.select { |t| t.category_id.nil? && t.is_uncategorized_investment }&.sum(&:total) || 0
|
||||||
|
else
|
||||||
|
totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
|
||||||
|
end
|
||||||
|
|
||||||
children_totals = if category == uncategorized_category
|
children_totals = if category.synthetic?
|
||||||
0
|
0
|
||||||
else
|
else
|
||||||
totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0
|
totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ class IncomeStatement::Totals
|
|||||||
category_id: row["category_id"],
|
category_id: row["category_id"],
|
||||||
classification: row["classification"],
|
classification: row["classification"],
|
||||||
total: row["total"],
|
total: row["total"],
|
||||||
transactions_count: row["transactions_count"]
|
transactions_count: row["transactions_count"],
|
||||||
|
is_uncategorized_investment: row["is_uncategorized_investment"]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
|
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :is_uncategorized_investment)
|
||||||
|
|
||||||
def query_sql
|
def query_sql
|
||||||
ActiveRecord::Base.sanitize_sql_array([
|
ActiveRecord::Base.sanitize_sql_array([
|
||||||
@@ -37,6 +38,7 @@ class IncomeStatement::Totals
|
|||||||
category_id,
|
category_id,
|
||||||
parent_category_id,
|
parent_category_id,
|
||||||
classification,
|
classification,
|
||||||
|
is_uncategorized_investment,
|
||||||
SUM(total) as total,
|
SUM(total) as total,
|
||||||
SUM(entry_count) as transactions_count
|
SUM(entry_count) as transactions_count
|
||||||
FROM (
|
FROM (
|
||||||
@@ -44,7 +46,7 @@ class IncomeStatement::Totals
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
#{trades_subquery_sql}
|
#{trades_subquery_sql}
|
||||||
) combined
|
) combined
|
||||||
GROUP BY category_id, parent_category_id, classification;
|
GROUP BY category_id, parent_category_id, classification, is_uncategorized_investment;
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -56,7 +58,8 @@ class IncomeStatement::Totals
|
|||||||
c.parent_id as parent_category_id,
|
c.parent_id as parent_category_id,
|
||||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||||
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||||
COUNT(ae.id) as transactions_count
|
COUNT(ae.id) as transactions_count,
|
||||||
|
false as is_uncategorized_investment
|
||||||
FROM (#{@transactions_scope.to_sql}) at
|
FROM (#{@transactions_scope.to_sql}) at
|
||||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||||
JOIN accounts a ON a.id = ae.account_id
|
JOIN accounts a ON a.id = ae.account_id
|
||||||
@@ -81,7 +84,8 @@ class IncomeStatement::Totals
|
|||||||
c.parent_id as parent_category_id,
|
c.parent_id as parent_category_id,
|
||||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||||
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||||
COUNT(ae.id) as entry_count
|
COUNT(ae.id) as entry_count,
|
||||||
|
false as is_uncategorized_investment
|
||||||
FROM (#{@transactions_scope.to_sql}) at
|
FROM (#{@transactions_scope.to_sql}) at
|
||||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||||
JOIN accounts a ON a.id = ae.account_id
|
JOIN accounts a ON a.id = ae.account_id
|
||||||
@@ -101,14 +105,15 @@ class IncomeStatement::Totals
|
|||||||
|
|
||||||
def trades_subquery_sql
|
def trades_subquery_sql
|
||||||
# Get trades for the same family and date range as transactions
|
# Get trades for the same family and date range as transactions
|
||||||
# Only include trades that have a category assigned
|
# Trades without categories appear as "Uncategorized Investments" (separate from regular uncategorized)
|
||||||
<<~SQL
|
<<~SQL
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.id as category_id,
|
||||||
c.parent_id as parent_category_id,
|
c.parent_id as parent_category_id,
|
||||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||||
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||||
COUNT(ae.id) as entry_count
|
COUNT(ae.id) as entry_count,
|
||||||
|
CASE WHEN t.category_id IS NULL THEN true ELSE false END as is_uncategorized_investment
|
||||||
FROM trades t
|
FROM trades t
|
||||||
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade'
|
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade'
|
||||||
JOIN accounts a ON a.id = ae.account_id
|
JOIN accounts a ON a.id = ae.account_id
|
||||||
@@ -122,8 +127,7 @@ class IncomeStatement::Totals
|
|||||||
AND a.status IN ('draft', 'active')
|
AND a.status IN ('draft', 'active')
|
||||||
AND ae.excluded = false
|
AND ae.excluded = false
|
||||||
AND ae.date BETWEEN :start_date AND :end_date
|
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, CASE WHEN t.category_id IS NULL THEN true ELSE false END
|
||||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -70,13 +70,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="py-3 shadow-border-xs rounded-lg bg-container font-medium text-sm max-w-full">
|
<div class="py-3 shadow-border-xs rounded-lg bg-container font-medium text-sm max-w-full">
|
||||||
<% outflows_data[:categories].each_with_index do |category, idx| %>
|
<% outflows_data[:categories].each_with_index do |category, idx| %>
|
||||||
<%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }),
|
<%
|
||||||
class: "flex items-center justify-between mx-3 p-3 rounded-lg cursor-pointer group gap-3",
|
category_content = capture do
|
||||||
data: {
|
%>
|
||||||
turbo_frame: "_top",
|
|
||||||
category_id: category[:id],
|
|
||||||
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
|
|
||||||
} do %>
|
|
||||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<div class="h-6 w-6 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
<div class="h-6 w-6 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
||||||
style="
|
style="
|
||||||
@@ -102,6 +98,24 @@
|
|||||||
<span class="text-sm text-secondary whitespace-nowrap w-10 lg:w-15"><%= category[:percentage] %>%</span>
|
<span class="text-sm text-secondary whitespace-nowrap w-10 lg:w-15"><%= category[:percentage] %>%</span>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if category[:clickable] != false %>
|
||||||
|
<%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }),
|
||||||
|
class: "flex items-center justify-between mx-3 p-3 rounded-lg cursor-pointer group gap-3",
|
||||||
|
data: {
|
||||||
|
turbo_frame: "_top",
|
||||||
|
category_id: category[:id],
|
||||||
|
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
|
||||||
|
} do %>
|
||||||
|
<%= category_content %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<div class="flex items-center justify-between mx-3 p-3 rounded-lg group gap-3"
|
||||||
|
data-category-id="<%= category[:id] %>"
|
||||||
|
data-action="mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment">
|
||||||
|
<%= category_content %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% if idx < outflows_data[:categories].size - 1 %>
|
<% if idx < outflows_data[:categories].size - 1 %>
|
||||||
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
|
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
73
app/views/reports/_net_worth.html.erb
Normal file
73
app/views/reports/_net_worth.html.erb
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<%# Main Net Worth Stats %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<%# Current Net Worth %>
|
||||||
|
<div class="p-4 bg-surface-inset rounded-lg">
|
||||||
|
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.current_net_worth") %></p>
|
||||||
|
<p class="text-xl font-semibold <%= net_worth_metrics[:current_net_worth] >= 0 ? "text-success" : "text-destructive" %>">
|
||||||
|
<%= net_worth_metrics[:current_net_worth].format %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Period Change %>
|
||||||
|
<div class="p-4 bg-surface-inset rounded-lg">
|
||||||
|
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.period_change") %></p>
|
||||||
|
<% if net_worth_metrics[:trend] %>
|
||||||
|
<% trend = net_worth_metrics[:trend] %>
|
||||||
|
<p class="text-xl font-semibold" style="color: <%= trend.color %>">
|
||||||
|
<%= trend.value.format(signify_positive: true) %>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs" style="color: <%= trend.color %>">
|
||||||
|
<%= trend.value >= 0 ? "+" : "" %><%= trend.percent_formatted %>
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-xl font-semibold text-tertiary">--</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Assets vs Liabilities %>
|
||||||
|
<div class="p-4 bg-surface-inset rounded-lg">
|
||||||
|
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.assets_vs_liabilities") %></p>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-lg font-semibold text-success"><%= net_worth_metrics[:total_assets].format %></span>
|
||||||
|
<span class="text-xs text-tertiary">-</span>
|
||||||
|
<span class="text-lg font-semibold text-destructive"><%= net_worth_metrics[:total_liabilities].format %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Asset/Liability Breakdown %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<%# Assets Summary %>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-secondary mb-3"><%= t("reports.net_worth.total_assets") %></h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<% net_worth_metrics[:asset_groups].each do |group| %>
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-tertiary/50">
|
||||||
|
<span class="text-sm text-primary"><%= group[:name] %></span>
|
||||||
|
<span class="text-sm font-medium text-success"><%= group[:total].format %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if net_worth_metrics[:asset_groups].empty? %>
|
||||||
|
<p class="text-sm text-tertiary py-2"><%= t("reports.net_worth.no_assets") %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Liabilities Summary %>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-secondary mb-3"><%= t("reports.net_worth.total_liabilities") %></h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<% net_worth_metrics[:liability_groups].each do |group| %>
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-tertiary/50">
|
||||||
|
<span class="text-sm text-primary"><%= group[:name] %></span>
|
||||||
|
<span class="text-sm font-medium text-destructive"><%= group[:total].format %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if net_worth_metrics[:liability_groups].empty? %>
|
||||||
|
<p class="text-sm text-tertiary py-2"><%= t("reports.net_worth.no_liabilities") %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
|
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
|
||||||
<span class="font-medium text-primary"><%= group[:category_name] %></span>
|
<span class="font-medium text-primary"><%= group[:category_name] %></span>
|
||||||
<span class="text-xs text-tertiary whitespace-nowrap">(<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)</span>
|
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4 text-right">
|
<td class="py-3 px-4 text-right">
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
|
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
|
||||||
<span class="font-medium text-primary"><%= group[:category_name] %></span>
|
<span class="font-medium text-primary"><%= group[:category_name] %></span>
|
||||||
<span class="text-xs text-tertiary whitespace-nowrap">(<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)</span>
|
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4 text-right">
|
<td class="py-3 px-4 text-right">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="space-y-8">
|
<div>
|
||||||
<%# Month-over-Month Trends %>
|
<%# Month-over-Month Trends %>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-secondary mb-4">
|
<h3 class="text-sm font-medium text-secondary mb-4">
|
||||||
@@ -78,121 +78,5 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Spending Patterns %>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-secondary mb-4">
|
|
||||||
<%= t("reports.trends.spending_patterns") %>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<% if spending_patterns[:weekday_count] + spending_patterns[:weekend_count] > 0 %>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<%# Weekday Spending %>
|
|
||||||
<div class="p-6 bg-surface-inset rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-4">
|
|
||||||
<%= icon("calendar", class: "w-5 h-5 text-primary") %>
|
|
||||||
<h4 class="font-medium text-primary"><%= t("reports.trends.weekday_spending") %></h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.total") %></p>
|
|
||||||
<p class="text-xl font-semibold text-primary">
|
|
||||||
<%= Money.new(spending_patterns[:weekday_total], Current.family.currency).format %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_per_transaction") %></p>
|
|
||||||
<p class="text-base font-medium text-secondary">
|
|
||||||
<%= Money.new(spending_patterns[:weekday_avg], Current.family.currency).format %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.transactions") %></p>
|
|
||||||
<p class="text-base font-medium text-secondary">
|
|
||||||
<%= spending_patterns[:weekday_count] %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%# Weekend Spending %>
|
|
||||||
<div class="p-6 bg-surface-inset rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-4">
|
|
||||||
<%= icon("calendar-check", class: "w-5 h-5 text-primary") %>
|
|
||||||
<h4 class="font-medium text-primary"><%= t("reports.trends.weekend_spending") %></h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.total") %></p>
|
|
||||||
<p class="text-xl font-semibold text-primary">
|
|
||||||
<%= Money.new(spending_patterns[:weekend_total], Current.family.currency).format %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_per_transaction") %></p>
|
|
||||||
<p class="text-base font-medium text-secondary">
|
|
||||||
<%= Money.new(spending_patterns[:weekend_avg], Current.family.currency).format %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.transactions") %></p>
|
|
||||||
<p class="text-base font-medium text-secondary">
|
|
||||||
<%= spending_patterns[:weekend_count] %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%# Comparison Insight %>
|
|
||||||
<% if spending_patterns[:weekday_avg] > 0 && spending_patterns[:weekend_avg] > 0 %>
|
|
||||||
<div class="mt-4 p-4 bg-container rounded-lg border border-tertiary">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<%= icon("lightbulb", class: "w-5 h-5 text-warning mt-0.5") %>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-primary mb-1">
|
|
||||||
<%= t("reports.trends.insight_title") %>
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-secondary">
|
|
||||||
<%
|
|
||||||
weekday = spending_patterns[:weekday_avg].to_f
|
|
||||||
weekend = spending_patterns[:weekend_avg].to_f
|
|
||||||
|
|
||||||
if weekend > weekday
|
|
||||||
percent_diff = ((weekend - weekday) / weekday * 100).round(0)
|
|
||||||
if percent_diff > 20
|
|
||||||
message = t("reports.trends.insight_higher_weekend", percent: percent_diff)
|
|
||||||
else
|
|
||||||
message = t("reports.trends.insight_similar")
|
|
||||||
end
|
|
||||||
elsif weekday > weekend
|
|
||||||
percent_diff = ((weekday - weekend) / weekend * 100).round(0)
|
|
||||||
if percent_diff > 20
|
|
||||||
message = t("reports.trends.insight_higher_weekday", percent: percent_diff)
|
|
||||||
else
|
|
||||||
message = t("reports.trends.insight_similar")
|
|
||||||
end
|
|
||||||
else
|
|
||||||
message = t("reports.trends.insight_similar")
|
|
||||||
end
|
|
||||||
%>
|
|
||||||
<%= message %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<div class="text-center py-8 text-tertiary">
|
|
||||||
<%= t("reports.trends.no_spending_data") %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
6
config/locales/models/category/en.yml
Normal file
6
config/locales/models/category/en.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
en:
|
||||||
|
models:
|
||||||
|
category:
|
||||||
|
uncategorized: Uncategorized
|
||||||
|
other_investments: Other Investments
|
||||||
@@ -69,8 +69,8 @@ en:
|
|||||||
add_transaction: Add Transaction
|
add_transaction: Add Transaction
|
||||||
add_account: Add Account
|
add_account: Add Account
|
||||||
transactions_breakdown:
|
transactions_breakdown:
|
||||||
title: Transactions Breakdown
|
title: Activity Breakdown
|
||||||
no_transactions: No transactions found for the selected period and filters
|
no_transactions: No activity found for the selected period and filters
|
||||||
filters:
|
filters:
|
||||||
title: Filters
|
title: Filters
|
||||||
category: Category
|
category: Category
|
||||||
@@ -102,12 +102,25 @@ en:
|
|||||||
expense: Expenses
|
expense: Expenses
|
||||||
income: Income
|
income: Income
|
||||||
uncategorized: Uncategorized
|
uncategorized: Uncategorized
|
||||||
transactions: transactions
|
entries:
|
||||||
|
one: entry
|
||||||
|
other: entries
|
||||||
percentage: "% of Total"
|
percentage: "% of Total"
|
||||||
pagination:
|
pagination:
|
||||||
showing: Showing %{count} transactions
|
showing:
|
||||||
|
one: Showing %{count} entry
|
||||||
|
other: Showing %{count} entries
|
||||||
previous: Previous
|
previous: Previous
|
||||||
next: Next
|
next: Next
|
||||||
|
net_worth:
|
||||||
|
title: Net Worth
|
||||||
|
current_net_worth: Current Net Worth
|
||||||
|
period_change: Period Change
|
||||||
|
assets_vs_liabilities: Assets vs Liabilities
|
||||||
|
total_assets: Assets
|
||||||
|
total_liabilities: Liabilities
|
||||||
|
no_assets: No assets
|
||||||
|
no_liabilities: No liabilities
|
||||||
investment_performance:
|
investment_performance:
|
||||||
title: Investment Performance
|
title: Investment Performance
|
||||||
portfolio_value: Portfolio Value
|
portfolio_value: Portfolio Value
|
||||||
|
|||||||
Reference in New Issue
Block a user