diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb index 571cd93ce..c810ffa25 100644 --- a/app/controllers/api/v1/categories_controller.rb +++ b/app/controllers/api/v1/categories_controller.rb @@ -62,11 +62,6 @@ class Api::V1::CategoriesController < Api::V1::BaseController end def apply_filters(query) - # Filter by classification (income/expense) - if params[:classification].present? - query = query.where(classification: params[:classification]) - end - # Filter for root categories only (no parent) if params[:roots_only].present? && ActiveModel::Type::Boolean.new.cast(params[:roots_only]) query = query.roots diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index b1516f863..6d5e6b9fc 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -87,6 +87,6 @@ class CategoriesController < ApplicationController end def category_params - params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon) + params.require(:category).permit(:name, :color, :parent_id, :lucide_icon) end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 7f2aa6785..29a00cdf8 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -16,11 +16,13 @@ class PagesController < ApplicationController family_currency = Current.family.currency # 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) + income_statement = Current.family.income_statement + income_totals = income_statement.income_totals(period: @period) + expense_totals = income_statement.expense_totals(period: @period) + net_totals = income_statement.net_category_totals(period: @period) - @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency) - @outflows_data = build_outflows_donut_data(expense_totals) + @cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency) + @outflows_data = build_outflows_donut_data(net_totals) @dashboard_sections = build_dashboard_sections @@ -143,7 +145,7 @@ class PagesController < ApplicationController Provider::Registry.get_provider(:github) end - def build_cashflow_sankey_data(income_totals, expense_totals, currency) + def build_cashflow_sankey_data(net_totals, income_totals, expense_totals, currency) nodes = [] links = [] node_indices = {} @@ -155,30 +157,33 @@ class PagesController < ApplicationController end } - total_income = income_totals.total.to_f.round(2) - total_expense = expense_totals.total.to_f.round(2) + total_income = net_totals.total_net_income.to_f.round(2) + total_expense = net_totals.total_net_expense.to_f.round(2) # Central Cash Flow node cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)") - # Process income categories (flow: subcategory -> parent -> cash_flow) - process_category_totals( - category_totals: income_totals.category_totals, + # Build netted subcategory data from raw totals + net_subcategories_by_parent = build_net_subcategories(expense_totals, income_totals) + + # Process net income categories (flow: subcategory -> parent -> cash_flow) + process_net_category_nodes( + categories: net_totals.net_income_categories, total: total_income, prefix: "income", - default_color: Category::UNCATEGORIZED_COLOR, + net_subcategories_by_parent: net_subcategories_by_parent, add_node: add_node, links: links, cash_flow_idx: cash_flow_idx, flow_direction: :inbound ) - # Process expense categories (flow: cash_flow -> parent -> subcategory) - process_category_totals( - category_totals: expense_totals.category_totals, + # Process net expense categories (flow: cash_flow -> parent -> subcategory) + process_net_category_nodes( + categories: net_totals.net_expense_categories, total: total_expense, prefix: "expense", - default_color: Category::UNCATEGORIZED_COLOR, + net_subcategories_by_parent: net_subcategories_by_parent, add_node: add_node, links: links, cash_flow_idx: cash_flow_idx, @@ -196,12 +201,124 @@ class PagesController < ApplicationController { nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol } end - def build_outflows_donut_data(expense_totals) - currency_symbol = Money::Currency.new(expense_totals.currency).symbol - total = expense_totals.total + # Nets subcategory expense and income totals, grouped by parent_id. + # Returns { parent_id => [ { category:, total: net_amount }, ... ] } + # Only includes subcategories with positive net (same direction as parent). + def build_net_subcategories(expense_totals, income_totals) + expense_subs = expense_totals.category_totals + .select { |ct| ct.category.parent_id.present? } + .index_by { |ct| ct.category.id } - categories = expense_totals.category_totals - .reject { |ct| ct.category.parent_id.present? || ct.total.zero? } + income_subs = income_totals.category_totals + .select { |ct| ct.category.parent_id.present? } + .index_by { |ct| ct.category.id } + + all_sub_ids = (expense_subs.keys + income_subs.keys).uniq + result = {} + + all_sub_ids.each do |sub_id| + exp_ct = expense_subs[sub_id] + inc_ct = income_subs[sub_id] + exp_total = exp_ct&.total || 0 + inc_total = inc_ct&.total || 0 + net = exp_total - inc_total + category = exp_ct&.category || inc_ct&.category + + next if net.zero? + + parent_id = category.parent_id + result[parent_id] ||= [] + result[parent_id] << { category: category, total: net.abs, net_direction: net > 0 ? :expense : :income } + end + + result + end + + # Builds sankey nodes/links for net categories with subcategory hierarchy. + # Subcategories matching the parent's flow direction are shown as children. + # Subcategories with opposite net direction appear on the OTHER side of the + # sankey (handled when the other side calls this method). + # + # flow_direction: :inbound (subcategory -> parent -> cash_flow) for income + # :outbound (cash_flow -> parent -> subcategory) for expenses + def process_net_category_nodes(categories:, total:, prefix:, net_subcategories_by_parent:, add_node:, links:, cash_flow_idx:, flow_direction:) + matching_direction = flow_direction == :inbound ? :income : :expense + + categories.each do |ct| + val = ct.total.to_f.round(2) + next if val.zero? + + percentage = total.zero? ? 0 : (val / total * 100).round(1) + color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR + node_key = "#{prefix}_#{ct.category.id || ct.category.name}" + + all_subs = ct.category.id ? (net_subcategories_by_parent[ct.category.id] || []) : [] + same_side_subs = all_subs.select { |s| s[:net_direction] == matching_direction } + + # Also check if any subcategory has opposite direction — those will be + # rendered by the OTHER side's call to this method, linked to cash_flow + # directly (they appear as independent nodes on the opposite side). + opposite_subs = all_subs.select { |s| s[:net_direction] != matching_direction } + + if same_side_subs.any? + parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color) + + if flow_direction == :inbound + links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } + else + links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage } + end + + same_side_subs.each do |sub| + sub_val = sub[:total].to_f.round(2) + sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1) + sub_color = sub[:category].color.presence || color + sub_key = "#{prefix}_sub_#{sub[:category].id}" + sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color) + + if flow_direction == :inbound + links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct } + else + links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } + end + end + else + idx = add_node.call(node_key, ct.category.name, val, percentage, color) + + if flow_direction == :inbound + links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } + else + links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage } + end + end + + # Render opposite-direction subcategories as standalone nodes on this side, + # linked directly to cash_flow. They represent subcategory surplus/deficit + # that goes against the parent's overall direction. + opposite_prefix = flow_direction == :inbound ? "expense" : "income" + opposite_subs.each do |sub| + sub_val = sub[:total].to_f.round(2) + sub_pct = total.zero? ? 0 : (sub_val / total * 100).round(1) + sub_color = sub[:category].color.presence || color + sub_key = "#{opposite_prefix}_sub_#{sub[:category].id}" + sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color) + + # Opposite direction: if parent is outbound (expense), this sub is inbound (income) + if flow_direction == :inbound + links << { source: cash_flow_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } + else + links << { source: sub_idx, target: cash_flow_idx, value: sub_val, color: sub_color, percentage: sub_pct } + end + end + end + end + + def build_outflows_donut_data(net_totals) + currency_symbol = Money::Currency.new(net_totals.currency).symbol + total = net_totals.total_net_expense + + categories = net_totals.net_expense_categories + .reject { |ct| ct.total.zero? } .sort_by { |ct| -ct.total } .map do |ct| { @@ -216,66 +333,7 @@ class PagesController < ApplicationController } end - { categories: categories, total: total.to_f.round(2), currency: expense_totals.currency, currency_symbol: currency_symbol } - end - - # Processes category totals for sankey diagram, handling parent/subcategory relationships. - # flow_direction: :inbound (subcategory -> parent -> cash_flow) for income - # :outbound (cash_flow -> parent -> subcategory) for expenses - def process_category_totals(category_totals:, total:, prefix:, default_color:, add_node:, links:, cash_flow_idx:, flow_direction:) - # Build lookup of subcategories by parent_id - subcategories_by_parent = category_totals - .select { |ct| ct.category.parent_id.present? && ct.total.to_f > 0 } - .group_by { |ct| ct.category.parent_id } - - category_totals.each do |ct| - next if ct.category.parent_id.present? # Skip subcategories in first pass - - val = ct.total.to_f.round(2) - next if val.zero? - - percentage = total.zero? ? 0 : (val / total * 100).round(1) - color = ct.category.color.presence || default_color - node_key = "#{prefix}_#{ct.category.id || ct.category.name}" - - subs = subcategories_by_parent[ct.category.id] || [] - - if subs.any? - parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color) - - # Link parent to/from cash flow based on direction - if flow_direction == :inbound - links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } - else - links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage } - end - - # Add subcategory nodes - subs.each do |sub_ct| - sub_val = sub_ct.total.to_f.round(2) - sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1) - sub_color = sub_ct.category.color.presence || color - sub_key = "#{prefix}_sub_#{sub_ct.category.id}" - sub_idx = add_node.call(sub_key, sub_ct.category.name, sub_val, sub_pct, sub_color) - - # Link subcategory to/from parent based on direction - if flow_direction == :inbound - links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct } - else - links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } - end - end - else - # No subcategories, link directly to/from cash flow - idx = add_node.call(node_key, ct.category.name, val, percentage, color) - - if flow_direction == :inbound - links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } - else - links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage } - end - end - end + { categories: categories, total: total.to_f.round(2), currency: net_totals.currency, currency_symbol: currency_symbol } end def ensure_intro_guest! diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 0b775826d..076943529 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -6,8 +6,7 @@ class TransactionsController < ApplicationController def new super - @income_categories = Current.family.categories.incomes.alphabetically - @expense_categories = Current.family.categories.expenses.alphabetically + @categories = Current.family.categories.alphabetically end def index diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 9579cadd0..e1fdac825 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -9,7 +9,7 @@ class TransfersController < ApplicationController end def show - @categories = Current.family.categories.expenses + @categories = Current.family.categories.alphabetically end def create diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index e7ff95e03..65ae6e43c 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -25,7 +25,6 @@ module ImportsHelper entity_type: "Type", category_parent: "Parent category", category_color: "Color", - category_classification: "Classification", category_icon: "Lucide icon" }[key] end diff --git a/app/models/budget.rb b/app/models/budget.rb index 0396fc30e..6c994ee1e 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -81,7 +81,7 @@ class Budget < ApplicationRecord end def sync_budget_categories - current_category_ids = family.categories.expenses.pluck(:id).to_set + current_category_ids = family.categories.pluck(:id).to_set existing_budget_category_ids = budget_categories.pluck(:category_id).to_set categories_to_add = current_category_ids - existing_budget_category_ids categories_to_remove = existing_budget_category_ids - current_category_ids @@ -157,11 +157,11 @@ class Budget < ApplicationRecord end def income_category_totals - income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse + net_totals.net_income_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse end def expense_category_totals - expense_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse + net_totals.net_expense_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse end def current? @@ -214,11 +214,11 @@ class Budget < ApplicationRecord end def actual_spending - [ expense_totals.total - refunds_in_expense_categories, 0 ].max + net_totals.total_net_expense end def budget_category_actual_spending(budget_category) - key = budget_category.category_id || budget_category.category.name + key = budget_category.category_id || stable_synthetic_key(budget_category.category) expense = expense_totals_by_category[key]&.total || 0 refund = income_totals_by_category[key]&.total || 0 [ expense - refund, 0 ].max @@ -297,31 +297,35 @@ class Budget < ApplicationRecord end private - def refunds_in_expense_categories - expense_category_ids = budget_categories.map(&:category_id).to_set - income_totals.category_totals - .reject { |ct| ct.category.subcategory? } - .select { |ct| expense_category_ids.include?(ct.category.id) || ct.category.uncategorized? } - .sum(&:total) - end - def income_statement @income_statement ||= family.income_statement end + def net_totals + @net_totals ||= income_statement.net_category_totals(period: period) + end + def expense_totals @expense_totals ||= income_statement.expense_totals(period: period) end def income_totals - @income_totals ||= family.income_statement.income_totals(period: period) + @income_totals ||= income_statement.income_totals(period: period) end def expense_totals_by_category - @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || ct.category.name } + @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) } end def income_totals_by_category - @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || ct.category.name } + @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) } + end + + def stable_synthetic_key(category) + if category.uncategorized? + :uncategorized + elsif category.other_investments? + :other_investments + end end end diff --git a/app/models/category.rb b/app/models/category.rb index 02acee0f6..743397456 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -12,7 +12,6 @@ class Category < ApplicationRecord validates :name, uniqueness: { scope: :family_id } validate :category_level_limit - validate :nested_category_matches_parent_classification before_save :inherit_color_from_parent @@ -24,8 +23,9 @@ class Category < ApplicationRecord .order(:name) } scope :roots, -> { where(parent_id: nil) } - scope :incomes, -> { where(classification: "income") } - scope :expenses, -> { where(classification: "expense") } + # Legacy scopes - classification removed; these now return all categories + scope :incomes, -> { all } + scope :expenses, -> { all } COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] @@ -79,10 +79,9 @@ class Category < ApplicationRecord end def bootstrap! - default_categories.each do |name, color, icon, classification| + default_categories.each do |name, color, icon| find_or_create_by!(name: name) do |category| category.color = color - category.classification = classification category.lucide_icon = icon end end @@ -138,28 +137,28 @@ class Category < ApplicationRecord private def default_categories [ - [ "Income", "#22c55e", "circle-dollar-sign", "income" ], - [ "Food & Drink", "#f97316", "utensils", "expense" ], - [ "Groceries", "#407706", "shopping-bag", "expense" ], - [ "Shopping", "#3b82f6", "shopping-cart", "expense" ], - [ "Transportation", "#0ea5e9", "bus", "expense" ], - [ "Travel", "#2563eb", "plane", "expense" ], - [ "Entertainment", "#a855f7", "drama", "expense" ], - [ "Healthcare", "#4da568", "pill", "expense" ], - [ "Personal Care", "#14b8a6", "scissors", "expense" ], - [ "Home Improvement", "#d97706", "hammer", "expense" ], - [ "Mortgage / Rent", "#b45309", "home", "expense" ], - [ "Utilities", "#eab308", "lightbulb", "expense" ], - [ "Subscriptions", "#6366f1", "wifi", "expense" ], - [ "Insurance", "#0284c7", "shield", "expense" ], - [ "Sports & Fitness", "#10b981", "dumbbell", "expense" ], - [ "Gifts & Donations", "#61c9ea", "hand-helping", "expense" ], - [ "Taxes", "#dc2626", "landmark", "expense" ], - [ "Loan Payments", "#e11d48", "credit-card", "expense" ], - [ "Services", "#7c3aed", "briefcase", "expense" ], - [ "Fees", "#6b7280", "receipt", "expense" ], - [ "Savings & Investments", "#059669", "piggy-bank", "expense" ], - [ investment_contributions_name, "#0d9488", "trending-up", "expense" ] + [ "Income", "#22c55e", "circle-dollar-sign" ], + [ "Food & Drink", "#f97316", "utensils" ], + [ "Groceries", "#407706", "shopping-bag" ], + [ "Shopping", "#3b82f6", "shopping-cart" ], + [ "Transportation", "#0ea5e9", "bus" ], + [ "Travel", "#2563eb", "plane" ], + [ "Entertainment", "#a855f7", "drama" ], + [ "Healthcare", "#4da568", "pill" ], + [ "Personal Care", "#14b8a6", "scissors" ], + [ "Home Improvement", "#d97706", "hammer" ], + [ "Mortgage / Rent", "#b45309", "home" ], + [ "Utilities", "#eab308", "lightbulb" ], + [ "Subscriptions", "#6366f1", "wifi" ], + [ "Insurance", "#0284c7", "shield" ], + [ "Sports & Fitness", "#10b981", "dumbbell" ], + [ "Gifts & Donations", "#61c9ea", "hand-helping" ], + [ "Taxes", "#dc2626", "landmark" ], + [ "Loan Payments", "#e11d48", "credit-card" ], + [ "Services", "#7c3aed", "briefcase" ], + [ "Fees", "#6b7280", "receipt" ], + [ "Savings & Investments", "#059669", "piggy-bank" ], + [ investment_contributions_name, "#0d9488", "trending-up" ] ] end end @@ -211,12 +210,6 @@ class Category < ApplicationRecord end end - def nested_category_matches_parent_classification - if subcategory? && parent.classification != classification - errors.add(:parent, "must have the same classification as its parent") - end - end - def monetizable_currency family.currency end diff --git a/app/models/category_import.rb b/app/models/category_import.rb index 59f8095f4..f58a50b70 100644 --- a/app/models/category_import.rb +++ b/app/models/category_import.rb @@ -5,7 +5,6 @@ class CategoryImport < Import category_name = row.name.to_s.strip category = family.categories.find_or_initialize_by(name: category_name) category.color = row.category_color.presence || category.color || Category::UNCATEGORIZED_COLOR - category.classification = row.category_classification.presence || category.classification || "expense" category.lucide_icon = row.category_icon.presence || category.lucide_icon || "shapes" category.parent = nil category.save! @@ -30,7 +29,7 @@ class CategoryImport < Import end def column_keys - %i[name category_color category_parent category_classification category_icon] + %i[name category_color category_parent category_icon] end def required_column_keys @@ -47,10 +46,10 @@ class CategoryImport < Import def csv_template template = <<-CSV - name*,color,parent_category,classification,lucide_icon - Food & Drink,#f97316,,expense,carrot - Groceries,#407706,Food & Drink,expense,shopping-basket - Salary,#22c55e,,income,briefcase + name*,color,parent_category,lucide_icon + Food & Drink,#f97316,,carrot + Groceries,#407706,Food & Drink,shopping-basket + Salary,#22c55e,,briefcase CSV CSV.parse(template, headers: true) @@ -64,7 +63,6 @@ class CategoryImport < Import name_header = header_for("name") color_header = header_for("color") parent_header = header_for("parent_category", "parent category") - classification_header = header_for("classification") icon_header = header_for("lucide_icon", "lucide icon", "icon") csv_rows.each do |row| @@ -72,7 +70,6 @@ class CategoryImport < Import name: row[name_header].to_s.strip, category_color: row[color_header].to_s.strip, category_parent: row[parent_header].to_s.strip, - category_classification: row[classification_header].to_s.strip, category_icon: row[icon_header].to_s.strip, currency: default_currency ) @@ -112,7 +109,6 @@ class CategoryImport < Import family.categories.find_or_create_by!(name: trimmed_name) do |placeholder| placeholder.color = Category::UNCATEGORIZED_COLOR - placeholder.classification = "expense" placeholder.lucide_icon = "shapes" end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index d44b16c83..474a1095c 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -213,36 +213,36 @@ class Demo::Generator def create_realistic_categories!(family) # Income categories (3 total) - @salary_cat = family.categories.create!(name: "Salary", color: "#10b981", classification: "income") - @freelance_cat = family.categories.create!(name: "Freelance", color: "#059669", classification: "income") - @investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857", classification: "income") + @salary_cat = family.categories.create!(name: "Salary", color: "#10b981") + @freelance_cat = family.categories.create!(name: "Freelance", color: "#059669") + @investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857") # Expense categories with subcategories (12 total) - @housing_cat = family.categories.create!(name: "Housing", color: "#dc2626", classification: "expense") - @rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c", classification: "expense") - @utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b", classification: "expense") + @housing_cat = family.categories.create!(name: "Housing", color: "#dc2626") + @rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c") + @utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b") - @food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c", classification: "expense") - @groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c", classification: "expense") - @restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412", classification: "expense") - @coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12", classification: "expense") + @food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c") + @groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c") + @restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412") + @coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12") - @transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb", classification: "expense") - @gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8", classification: "expense") - @car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af", classification: "expense") + @transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb") + @gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8") + @car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af") - @entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed", classification: "expense") - @healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777", classification: "expense") - @shopping_cat = family.categories.create!(name: "Shopping", color: "#059669", classification: "expense") - @travel_cat = family.categories.create!(name: "Travel", color: "#0891b2", classification: "expense") - @personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d", classification: "expense") + @entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed") + @healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777") + @shopping_cat = family.categories.create!(name: "Shopping", color: "#059669") + @travel_cat = family.categories.create!(name: "Travel", color: "#0891b2") + @personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d") # Additional high-level expense categories to reach 13 top-level items - @insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1", classification: "expense") - @misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280", classification: "expense") + @insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1") + @misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280") # Interest expense bucket - @interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569", classification: "expense") + @interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569") end def create_realistic_accounts!(family) @@ -354,11 +354,11 @@ class Demo::Generator analysis_start = (current_month - 3.months).beginning_of_month analysis_period = analysis_start..(current_month - 1.day) - # Fetch expense transactions in the analysis period + # Fetch expense transactions in the analysis period (positive amounts = expenses) txns = Entry.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id") .joins("INNER JOIN categories ON categories.id = transactions.category_id") .where(entries: { entryable_type: "Transaction", date: analysis_period }) - .where(categories: { classification: "expense" }) + .where("entries.amount > 0") spend_per_cat = txns.group("categories.id").sum("entries.amount") diff --git a/app/models/family.rb b/app/models/family.rb index 75cae5bf0..a43b118a2 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -155,7 +155,6 @@ class Family < ApplicationRecord I18n.with_locale(locale) do categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat| cat.color = "#0d9488" - cat.classification = "expense" cat.lucide_icon = "trending-up" end end diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 1efb76c58..c3b452dbc 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -69,8 +69,7 @@ class Family::AutoCategorizer id: category.id, name: category.name, is_subcategory: category.subcategory?, - parent_id: category.parent_id, - classification: category.classification + parent_id: category.parent_id } end end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 6b3be40fd..3eacd9fd2 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -105,7 +105,7 @@ class Family::DataExporter def generate_categories_csv CSV.generate do |csv| - csv << [ "name", "color", "parent_category", "classification", "lucide_icon" ] + csv << [ "name", "color", "parent_category", "lucide_icon" ] # Only export categories belonging to this family @family.categories.includes(:parent).find_each do |category| @@ -113,7 +113,6 @@ class Family::DataExporter category.name, category.color, category.parent&.name, - category.classification, category.lucide_icon ] end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index 83aa2c9fd..8f1b8f0a9 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -36,6 +36,65 @@ class IncomeStatement build_period_total(classification: "income", period: period) end + def net_category_totals(period: Period.current_month) + expense = expense_totals(period: period) + income = income_totals(period: period) + + # Use a stable key for each category: id for persisted, invariant token for synthetic + cat_key = ->(ct) { + if ct.category.uncategorized? + :uncategorized + elsif ct.category.other_investments? + :other_investments + else + ct.category.id + end + } + + expense_by_cat = expense.category_totals.reject { |ct| ct.category.subcategory? }.index_by { |ct| cat_key.call(ct) } + income_by_cat = income.category_totals.reject { |ct| ct.category.subcategory? }.index_by { |ct| cat_key.call(ct) } + + all_keys = (expense_by_cat.keys + income_by_cat.keys).uniq + raw_expense_categories = [] + raw_income_categories = [] + + all_keys.each do |key| + exp_ct = expense_by_cat[key] + inc_ct = income_by_cat[key] + exp_total = exp_ct&.total || 0 + inc_total = inc_ct&.total || 0 + net = exp_total - inc_total + category = exp_ct&.category || inc_ct&.category + + if net > 0 + raw_expense_categories << { category: category, total: net } + elsif net < 0 + raw_income_categories << { category: category, total: net.abs } + end + end + + total_net_expense = raw_expense_categories.sum { |r| r[:total] } + total_net_income = raw_income_categories.sum { |r| r[:total] } + + net_expense_categories = raw_expense_categories.map do |r| + weight = total_net_expense.zero? ? 0 : (r[:total].to_f / total_net_expense) * 100 + CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight) + end + + net_income_categories = raw_income_categories.map do |r| + weight = total_net_income.zero? ? 0 : (r[:total].to_f / total_net_income) * 100 + CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight) + end + + NetCategoryTotals.new( + net_expense_categories: net_expense_categories, + net_income_categories: net_income_categories, + total_net_expense: total_net_expense, + total_net_income: total_net_income, + currency: family.currency + ) + end + def median_expense(interval: "month", category: nil) if category.present? category_stats(interval: interval).find { |stat| stat.classification == "expense" && stat.category_id == category.id }&.median || 0 @@ -60,6 +119,7 @@ class IncomeStatement ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money) PeriodTotal = Data.define(:classification, :total, :currency, :category_totals) CategoryTotal = Data.define(:category, :total, :currency, :weight) + NetCategoryTotals = Data.define(:net_expense_categories, :net_income_categories, :total_net_expense, :total_net_income, :currency) def categories @categories ||= family.categories.all.to_a diff --git a/app/models/plaid_account/transactions/category_matcher.rb b/app/models/plaid_account/transactions/category_matcher.rb index 87652109f..263ec0445 100644 --- a/app/models/plaid_account/transactions/category_matcher.rb +++ b/app/models/plaid_account/transactions/category_matcher.rb @@ -97,7 +97,6 @@ class PlaidAccount::Transactions::CategoryMatcher user_categories.map do |user_category| { id: user_category.id, - classification: user_category.classification, name: normalize_user_category_name(user_category.name) } end diff --git a/app/models/provider/openai/auto_categorizer.rb b/app/models/provider/openai/auto_categorizer.rb index 36cdf80bf..3c31c9d5b 100644 --- a/app/models/provider/openai/auto_categorizer.rb +++ b/app/models/provider/openai/auto_categorizer.rb @@ -105,7 +105,7 @@ class Provider::Openai::AutoCategorizer - Return 1 result per transaction - Correlate each transaction by ID (transaction_id) - Attempt to match the most specific category possible (i.e. subcategory over parent category) - - Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense") + - Any category can be used for any transaction regardless of whether the transaction is income or expense - If you don't know the category, return "null" - You should always favor "null" over false positives - Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one. diff --git a/app/models/rule_import.rb b/app/models/rule_import.rb index d2a2d07ca..bae5820d7 100644 --- a/app/models/rule_import.rb +++ b/app/models/rule_import.rb @@ -212,7 +212,6 @@ class RuleImport < Import category = family.categories.create!( name: value, color: Category::UNCATEGORIZED_COLOR, - classification: "expense", lucide_icon: "shapes" ) end @@ -245,7 +244,6 @@ class RuleImport < Import category = family.categories.create!( name: value, color: Category::UNCATEGORIZED_COLOR, - classification: "expense", lucide_icon: "shapes" ) end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 86e726e4c..ec2fce04e 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -44,7 +44,7 @@ <% if @mercury_items.any? %> <%= render @mercury_items.sort_by(&:created_at) %> <% end %> - + <% if @coinbase_items.any? %> <%= render @coinbase_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/api/v1/categories/_category.json.jbuilder b/app/views/api/v1/categories/_category.json.jbuilder index f0ebfe0cf..926df6584 100644 --- a/app/views/api/v1/categories/_category.json.jbuilder +++ b/app/views/api/v1/categories/_category.json.jbuilder @@ -2,7 +2,6 @@ json.id category.id json.name category.name -json.classification category.classification json.color category.color json.icon category.lucide_icon diff --git a/app/views/api/v1/transactions/_transaction.json.jbuilder b/app/views/api/v1/transactions/_transaction.json.jbuilder index 617f47505..9f3a47a98 100644 --- a/app/views/api/v1/transactions/_transaction.json.jbuilder +++ b/app/views/api/v1/transactions/_transaction.json.jbuilder @@ -31,7 +31,6 @@ if transaction.category.present? json.category do json.id transaction.category.id json.name transaction.category.name - json.classification transaction.category.classification json.color transaction.category.color json.icon transaction.category.lucide_icon end diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index d699d3c4d..3b9e6f1b4 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -62,7 +62,6 @@ <% end %>
<%= t(".moniker_prompt", product_name: product_name) %>