diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index f0c41dc53..db76e9d1d 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -154,7 +154,9 @@ class PagesController < ApplicationController percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1) 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 } end @@ -168,7 +170,9 @@ class PagesController < ApplicationController percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1) 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 } end @@ -198,7 +202,8 @@ class PagesController < ApplicationController currency: ct.currency, percentage: ct.weight.round(1), color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR, - icon: ct.category.lucide_icon + icon: ct.category.lucide_icon, + clickable: !ct.category.other_investments? } end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 8062a1ca4..bfb833acb 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -31,8 +31,8 @@ class ReportsController < ApplicationController # Build trend data (last 6 months) @trends_data = build_trends_data - # Spending patterns (weekday vs weekend) - @spending_patterns = build_spending_patterns + # Net worth metrics + @net_worth_metrics = build_net_worth_metrics # Transactions breakdown @transactions = build_transactions_breakdown @@ -124,11 +124,19 @@ class ReportsController < ApplicationController def build_reports_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", title: "reports.trends.title", partial: "reports/trends_insights", - locals: { trends_data: @trends_data, spending_patterns: @spending_patterns }, + locals: { trends_data: @trends_data }, visible: Current.family.transactions.any?, collapsible: true }, @@ -310,61 +318,6 @@ class ReportsController < ApplicationController trends 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 # Base query: all transactions in the period # Exclude transfers, one-time, and CC payments (matching income_statement logic) @@ -379,25 +332,55 @@ class ReportsController < ApplicationController # Apply filters 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 sort_by = params[:sort_by] || "amount" sort_direction = params[:sort_direction] || "desc" # Group by category and type - all_transactions = transactions.to_a grouped_data = {} + family_currency = Current.family.currency - all_transactions.each do |transaction| + # Process transactions + transactions.each do |transaction| entry = transaction.entry is_expense = entry.amount > 0 type = is_expense ? "expense" : "income" 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 ] grouped_data[key] ||= { total: 0, count: 0 } 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 # Convert to array @@ -438,6 +421,39 @@ class ReportsController < ApplicationController } 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) # Filter by category (including subcategories) if params[:filter_category_id].present? @@ -533,9 +549,19 @@ class ReportsController < ApplicationController transactions = apply_transaction_filters(transactions) - # Group transactions by category, type, and month - breakdown = {} + # 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: []) + # Group by category, type, and month + breakdown = {} + family_currency = Current.family.currency + + # Process transactions transactions.each do |transaction| entry = transaction.entry is_expense = entry.amount > 0 @@ -543,11 +569,33 @@ class ReportsController < ApplicationController category_name = transaction.category&.name || "Uncategorized" 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] += entry.amount.abs - breakdown[key][:total] += entry.amount.abs + breakdown[key][:months][month_key] += converted_amount + 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 # Convert to array and sort by type and total (descending) diff --git a/app/models/category.rb b/app/models/category.rb index 1a50db9ae..9561cd4ee 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -31,10 +31,15 @@ class Category < ApplicationRecord COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] UNCATEGORIZED_COLOR = "#737373" + OTHER_INVESTMENTS_COLOR = "#e99537" TRANSFER_COLOR = "#444CE7" PAYMENT_COLOR = "#db5a54" 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 attr_reader :category, :subcategories @@ -82,12 +87,30 @@ class Category < ApplicationRecord def uncategorized new( - name: "Uncategorized", + name: I18n.t(UNCATEGORIZED_NAME_KEY), color: UNCATEGORIZED_COLOR, lucide_icon: "circle-dashed" ) 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 def default_categories [ @@ -142,6 +165,21 @@ class Category < ApplicationRecord subcategory? ? "#{parent.name} > #{name}" : name 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 def category_level_limit if (subcategory? && parent.subcategory?) || (parent? && subcategory?) diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index 17db4cdcb..6c4e16b90 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -68,13 +68,22 @@ class IncomeStatement classification_total = totals.sum(&:total) 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 } - 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 else totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0 diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 7d4726b3a..355212486 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -15,13 +15,14 @@ class IncomeStatement::Totals category_id: row["category_id"], classification: row["classification"], total: row["total"], - transactions_count: row["transactions_count"] + transactions_count: row["transactions_count"], + is_uncategorized_investment: row["is_uncategorized_investment"] ) end end 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 ActiveRecord::Base.sanitize_sql_array([ @@ -37,6 +38,7 @@ class IncomeStatement::Totals category_id, parent_category_id, classification, + is_uncategorized_investment, SUM(total) as total, SUM(entry_count) as transactions_count FROM ( @@ -44,7 +46,7 @@ class IncomeStatement::Totals UNION ALL #{trades_subquery_sql} ) combined - GROUP BY category_id, parent_category_id, classification; + GROUP BY category_id, parent_category_id, classification, is_uncategorized_investment; SQL end @@ -56,7 +58,8 @@ class IncomeStatement::Totals 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 transactions_count + COUNT(ae.id) as transactions_count, + false as is_uncategorized_investment 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 @@ -81,7 +84,8 @@ class IncomeStatement::Totals 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 + COUNT(ae.id) as entry_count, + false as is_uncategorized_investment 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 @@ -101,14 +105,15 @@ class IncomeStatement::Totals def trades_subquery_sql # 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 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 + 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 JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade' JOIN accounts a ON a.id = ae.account_id @@ -122,8 +127,7 @@ class IncomeStatement::Totals 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 + 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 SQL end diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb index 4b11de35c..ac09ae4d4 100644 --- a/app/views/pages/dashboard/_outflows_donut.html.erb +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -70,13 +70,9 @@
<%= t("reports.net_worth.current_net_worth") %>
+"> + <%= net_worth_metrics[:current_net_worth].format %> +
+<%= t("reports.net_worth.period_change") %>
+ <% if net_worth_metrics[:trend] %> + <% trend = net_worth_metrics[:trend] %> ++ <%= trend.value.format(signify_positive: true) %> +
++ <%= trend.value >= 0 ? "+" : "" %><%= trend.percent_formatted %> +
+ <% else %> +--
+ <% end %> +<%= t("reports.net_worth.assets_vs_liabilities") %>
+<%= t("reports.net_worth.no_assets") %>
+ <% end %> +<%= t("reports.net_worth.no_liabilities") %>
+ <% end %> +<%= t("reports.trends.total") %>
-- <%= Money.new(spending_patterns[:weekday_total], Current.family.currency).format %> -
-<%= t("reports.trends.avg_per_transaction") %>
-- <%= Money.new(spending_patterns[:weekday_avg], Current.family.currency).format %> -
-<%= t("reports.trends.transactions") %>
-- <%= spending_patterns[:weekday_count] %> -
-<%= t("reports.trends.total") %>
-- <%= Money.new(spending_patterns[:weekend_total], Current.family.currency).format %> -
-<%= t("reports.trends.avg_per_transaction") %>
-- <%= Money.new(spending_patterns[:weekend_avg], Current.family.currency).format %> -
-<%= t("reports.trends.transactions") %>
-- <%= spending_patterns[:weekend_count] %> -
-- <%= t("reports.trends.insight_title") %> -
-- <% - 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 %> -
-