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 %>
- <%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %> <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %> <% unless category.parent? %> <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %> diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index c9c094396..8c5391831 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -22,13 +22,7 @@
<% if @categories.any? %>
- <% if @categories.incomes.any? %> - <%= render "categories/category_list_group", title: t(".categories_incomes"), categories: @categories.incomes %> - <% end %> - - <% if @categories.expenses.any? %> - <%= render "categories/category_list_group", title: t(".categories_expenses"), categories: @categories.expenses %> - <% end %> + <%= render "categories/category_list_group", title: t(".categories"), categories: @categories %>
<% else %>
diff --git a/app/views/oidc_accounts/link.html.erb b/app/views/oidc_accounts/link.html.erb index d01b7c2db..c4bf1aaf3 100644 --- a/app/views/oidc_accounts/link.html.erb +++ b/app/views/oidc_accounts/link.html.erb @@ -76,4 +76,4 @@ variant: :default, class: "font-medium text-sm text-primary hover:underline transition" ) %> -
\ No newline at end of file +
diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb index 694bc97e8..38d90b378 100644 --- a/app/views/onboardings/show.html.erb +++ b/app/views/onboardings/show.html.erb @@ -39,8 +39,7 @@ data-onboarding-household-name-label-value="<%= t(".household_name") %>" data-onboarding-household-name-placeholder-value="<%= t(".household_name_placeholder") %>" data-onboarding-group-name-label-value="<%= t(".group_name") %>" - data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>" - > + data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>">

<%= t(".moniker_prompt", product_name: product_name) %>

<% else %> <%= render "recurring_transactions/empty" %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/transactions/new.html.erb b/app/views/transactions/new.html.erb index ea9d27300..f3fddcfdf 100644 --- a/app/views/transactions/new.html.erb +++ b/app/views/transactions/new.html.erb @@ -1,6 +1,6 @@ <%= render DS::Dialog.new do |dialog| %> <% dialog.with_header(title: "New transaction") %> <% dialog.with_body do %> - <%= render "form", entry: @entry, income_categories: @income_categories, expense_categories: @expense_categories %> + <%= render "form", entry: @entry, categories: @categories %> <% end %> <% end %> diff --git a/db/migrate/20260308113006_remove_classification_from_categories.rb b/db/migrate/20260308113006_remove_classification_from_categories.rb new file mode 100644 index 000000000..b2862fc29 --- /dev/null +++ b/db/migrate/20260308113006_remove_classification_from_categories.rb @@ -0,0 +1,9 @@ +class RemoveClassificationFromCategories < ActiveRecord::Migration[7.2] + def up + rename_column :categories, :classification, :classification_unused + end + + def down + rename_column :categories, :classification_unused, :classification + end +end diff --git a/db/schema.rb b/db/schema.rb index a1cc7b39f..9c6837f21 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_03_03_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_03_08_113006) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -184,8 +184,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_03_120000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "parent_id" - t.string "classification", default: "expense", null: false t.string "lucide_icon", default: "shapes", null: false + t.string "classification_unused", default: "expense", null: false t.index ["family_id"], name: "index_categories_on_family_id" end diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index 6868e3979..90e06d5c2 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -36,7 +36,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:parent_category) do family.categories.create!( name: 'Food & Drink', - classification: 'expense', color: '#f97316', lucide_icon: 'utensils' ) @@ -45,7 +44,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:subcategory) do family.categories.create!( name: 'Restaurants', - classification: 'expense', color: '#f97316', lucide_icon: 'utensils', parent: parent_category @@ -55,7 +53,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:income_category) do family.categories.create!( name: 'Salary', - classification: 'income', color: '#22c55e', lucide_icon: 'circle-dollar-sign' ) @@ -70,9 +67,6 @@ RSpec.describe 'API V1 Categories', type: :request do description: 'Page number (default: 1)' parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page (default: 25, max: 100)' - parameter name: :classification, in: :query, required: false, - description: 'Filter by classification (income or expense)', - schema: { type: :string, enum: %w[income expense] } parameter name: :roots_only, in: :query, required: false, description: 'Return only root categories (no parent)', schema: { type: :boolean } @@ -86,14 +80,6 @@ RSpec.describe 'API V1 Categories', type: :request do run_test! end - response '200', 'categories filtered by classification' do - schema '$ref' => '#/components/schemas/CategoryCollection' - - let(:classification) { 'expense' } - - run_test! - end - response '200', 'root categories only' do schema '$ref' => '#/components/schemas/CategoryCollection' diff --git a/spec/requests/api/v1/trades_spec.rb b/spec/requests/api/v1/trades_spec.rb index 1eac1032d..97a40a03a 100644 --- a/spec/requests/api/v1/trades_spec.rb +++ b/spec/requests/api/v1/trades_spec.rb @@ -54,7 +54,6 @@ RSpec.describe 'API V1 Trades', type: :request do let(:category) do family.categories.create!( name: 'Investments', - classification: 'expense', color: '#2196F3', lucide_icon: 'trending-up' ) diff --git a/spec/requests/api/v1/transactions_spec.rb b/spec/requests/api/v1/transactions_spec.rb index 5e114c705..a4eab7590 100644 --- a/spec/requests/api/v1/transactions_spec.rb +++ b/spec/requests/api/v1/transactions_spec.rb @@ -46,7 +46,6 @@ RSpec.describe 'API V1 Transactions', type: :request do let(:category) do family.categories.create!( name: 'Groceries', - classification: 'expense', color: '#4CAF50', lucide_icon: 'shopping-cart' ) diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index c02b0831c..1a4c6d835 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -203,11 +203,10 @@ RSpec.configure do |config| }, Category: { type: :object, - required: %w[id name classification color icon], + required: %w[id name color icon], properties: { id: { type: :string, format: :uuid }, name: { type: :string }, - classification: { type: :string }, color: { type: :string }, icon: { type: :string } } @@ -222,11 +221,10 @@ RSpec.configure do |config| }, CategoryDetail: { type: :object, - required: %w[id name classification color icon subcategories_count created_at updated_at], + required: %w[id name color icon subcategories_count created_at updated_at], properties: { id: { type: :string, format: :uuid }, name: { type: :string }, - classification: { type: :string, enum: %w[income expense] }, color: { type: :string }, icon: { type: :string }, parent: { '$ref' => '#/components/schemas/CategoryParent', nullable: true }, diff --git a/test/controllers/api/v1/categories_controller_test.rb b/test/controllers/api/v1/categories_controller_test.rb index 9f4e87630..d3a26fbbc 100644 --- a/test/controllers/api/v1/categories_controller_test.rb +++ b/test/controllers/api/v1/categories_controller_test.rb @@ -84,7 +84,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest category = response_body["categories"].find { |c| c["name"] == @category.name } assert category.present?, "Should find the food_and_drink category" - required_fields = %w[id name classification color icon subcategories_count created_at updated_at] + required_fields = %w[id name color icon subcategories_count created_at updated_at] required_fields.each do |field| assert category.key?(field), "Category should have #{field} field" end @@ -124,19 +124,6 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest assert_equal 2, response_body["pagination"]["per_page"] end - test "should filter by classification" do - get "/api/v1/categories", params: { classification: "expense" }, headers: { - "Authorization" => "Bearer #{@access_token.token}" - } - - assert_response :success - response_body = JSON.parse(response.body) - - response_body["categories"].each do |category| - assert_equal "expense", category["classification"] - end - end - test "should filter for roots only" do get "/api/v1/categories", params: { roots_only: true }, headers: { "Authorization" => "Bearer #{@access_token.token}" @@ -174,7 +161,6 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest assert_equal @category.id, response_body["id"] assert_equal @category.name, response_body["name"] - assert_equal @category.classification, response_body["classification"] assert_equal @category.color, response_body["color"] assert_equal @category.lucide_icon, response_body["icon"] end diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 2c636ffd9..73c39f5a1 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -31,8 +31,8 @@ class PagesControllerTest < ActionDispatch::IntegrationTest test "dashboard renders sankey chart with subcategories" do # Create parent category with subcategory - parent_category = @family.categories.create!(name: "Shopping", classification: "expense", color: "#FF5733") - subcategory = @family.categories.create!(name: "Groceries", classification: "expense", parent: parent_category, color: "#33FF57") + parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733") + subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57") # Create transactions using helper create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category) diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb index 1c7e7bbd2..6f041c0da 100644 --- a/test/controllers/reports_controller_test.rb +++ b/test/controllers/reports_controller_test.rb @@ -118,8 +118,7 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest test "spending patterns returns data when expense transactions exist" do # Create expense category expense_category = @family.categories.create!( - name: "Test Groceries", - classification: "expense" + name: "Test Groceries" ) # Create account @@ -228,9 +227,9 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest test "index groups transactions by parent and subcategories" do # Create parent category with subcategories - parent_category = @family.categories.create!(name: "Entertainment", classification: "expense", color: "#FF5733") - subcategory_movies = @family.categories.create!(name: "Movies", classification: "expense", parent: parent_category, color: "#33FF57") - subcategory_games = @family.categories.create!(name: "Games", classification: "expense", parent: parent_category, color: "#5733FF") + parent_category = @family.categories.create!(name: "Entertainment", color: "#FF5733") + subcategory_movies = @family.categories.create!(name: "Movies", parent: parent_category, color: "#33FF57") + subcategory_games = @family.categories.create!(name: "Games", parent: parent_category, color: "#5733FF") # Create transactions using helper create_transaction(account: @family.accounts.first, name: "Cinema ticket", amount: 15, category: subcategory_movies) diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb index 4e16c4f88..5814fe9d8 100644 --- a/test/models/budget_category_test.rb +++ b/test/models/budget_category_test.rb @@ -10,23 +10,20 @@ class BudgetCategoryTest < ActiveSupport::TestCase name: "Test Food & Groceries #{Time.now.to_f}", family: @family, color: "#4da568", - lucide_icon: "utensils", - classification: "expense" + lucide_icon: "utensils" ) # Create subcategories with unique names @subcategory_with_limit = Category.create!( name: "Test Restaurants #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) @subcategory_inheriting = Category.create!( name: "Test Groceries #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) # Create budget categories @@ -95,8 +92,7 @@ class BudgetCategoryTest < ActiveSupport::TestCase another_inheriting = Category.create!( name: "Test Coffee #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) another_inheriting_bc = BudgetCategory.create!( @@ -114,8 +110,7 @@ class BudgetCategoryTest < ActiveSupport::TestCase new_subcategory_cat = Category.create!( name: "Test Fast Food #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) new_subcategory_bc = BudgetCategory.create!( @@ -143,8 +138,7 @@ class BudgetCategoryTest < ActiveSupport::TestCase name: "Test Entertainment #{Time.now.to_f}", family: @family, color: "#a855f7", - lucide_icon: "drama", - classification: "expense" + lucide_icon: "drama" ) standalone_bc = BudgetCategory.create!( diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index e12b51d49..f681bebc2 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -82,8 +82,7 @@ class BudgetTest < ActiveSupport::TestCase healthcare = Category.create!( name: "Healthcare #{Time.now.to_f}", family: family, - color: "#e74c3c", - classification: "expense" + color: "#e74c3c" ) budget.sync_budget_categories @@ -129,8 +128,7 @@ class BudgetTest < ActiveSupport::TestCase category = Category.create!( name: "Returns Only #{Time.now.to_f}", family: family, - color: "#3498db", - classification: "expense" + color: "#3498db" ) budget.sync_budget_categories @@ -266,7 +264,7 @@ class BudgetTest < ActiveSupport::TestCase source_budget.update!(budgeted_spending: 4000, expected_income: 6000) # Create a category only in the source budget - temp_category = Category.create!(name: "Temp #{Time.now.to_f}", family: family, color: "#aaa", classification: "expense") + temp_category = Category.create!(name: "Temp #{Time.now.to_f}", family: family, color: "#aaa") source_budget.budget_categories.create!(category: temp_category, budgeted_spending: 100, currency: "USD") target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) @@ -285,7 +283,7 @@ class BudgetTest < ActiveSupport::TestCase target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) # Add a new category only to the target - new_category = Category.create!(name: "New #{Time.now.to_f}", family: family, color: "#bbb", classification: "expense") + new_category = Category.create!(name: "New #{Time.now.to_f}", family: family, color: "#bbb") target_budget.budget_categories.create!(category: new_category, budgeted_spending: 0, currency: "USD") target_budget.copy_from!(source_budget) diff --git a/test/models/category_import_test.rb b/test/models/category_import_test.rb index 99e645c33..92128bd22 100644 --- a/test/models/category_import_test.rb +++ b/test/models/category_import_test.rb @@ -4,10 +4,10 @@ class CategoryImportTest < ActiveSupport::TestCase setup do @family = families(:dylan_family) @csv = <<~CSV - name,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot - Groceries,#407706,Food & Drink,expense,shopping-basket - Salary,#22c55e,,income,briefcase + name,color,parent_category,icon + Food & Drink,#f97316,,carrot + Groceries,#407706,Food & Drink,shopping-basket + Salary,#22c55e,,briefcase CSV end @@ -26,19 +26,17 @@ class CategoryImportTest < ActiveSupport::TestCase groceries = Category.find_by!(family: @family, name: "Groceries") salary = Category.find_by!(family: @family, name: "Salary") - assert_equal "expense", food.classification assert_equal "carrot", food.lucide_icon assert_equal food, groceries.parent assert_equal "shopping-basket", groceries.lucide_icon - assert_equal "income", salary.classification assert_equal "briefcase", salary.lucide_icon end test "imports subcategories even when parent row comes later" do csv = <<~CSV - name,color,parent_category,classification,icon - Utilities,#407706,Household,expense,plug - Household,#f97316,,expense,house + name,color,parent_category,icon + Utilities,#407706,Household,plug + Household,#f97316,,house CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -55,9 +53,9 @@ class CategoryImportTest < ActiveSupport::TestCase test "updates categories when duplicate rows are provided" do csv = <<~CSV - name,color,parent_category,classification,icon - Snacks,#aaaaaa,,expense,cookie - Snacks,#bbbbbb,,expense,pizza + name,color,parent_category,icon + Snacks,#aaaaaa,,cookie + Snacks,#bbbbbb,,pizza CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -72,8 +70,8 @@ class CategoryImportTest < ActiveSupport::TestCase test "accepts required headers with an asterisk suffix" do csv = <<~CSV - name*,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot + name*,color,parent_category,icon + Food & Drink,#f97316,,carrot CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -85,8 +83,8 @@ class CategoryImportTest < ActiveSupport::TestCase test "fails fast when required headers are missing" do csv = <<~CSV - title,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot + title,color,parent_category,icon + Food & Drink,#f97316,,carrot CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index 9f36e16bf..e25ed0e5d 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -73,7 +73,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase # Check categories.csv categories_csv = zip.read("categories.csv") - assert categories_csv.include?("name,color,parent_category,classification,lucide_icon") + assert categories_csv.include?("name,color,parent_category,lucide_icon") # Check rules.csv rules_csv = zip.read("rules.csv") diff --git a/test/models/family_test.rb b/test/models/family_test.rb index bfff617be..69a530316 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -18,7 +18,6 @@ class FamilyTest < ActiveSupport::TestCase assert category.persisted? assert_equal Category.investment_contributions_name, category.name assert_equal "#0d9488", category.color - assert_equal "expense", category.classification assert_equal "trending-up", category.lucide_icon end @@ -26,7 +25,6 @@ class FamilyTest < ActiveSupport::TestCase family = families(:dylan_family) existing = family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| c.color = "#0d9488" - c.classification = "expense" c.lucide_icon = "trending-up" end @@ -89,7 +87,6 @@ class FamilyTest < ActiveSupport::TestCase legacy_category = family.categories.create!( name: "Investment Contributions", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) @@ -110,14 +107,12 @@ class FamilyTest < ActiveSupport::TestCase english_category = family.categories.create!( name: "Investment Contributions", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) french_category = family.categories.create!( name: "Contributions aux investissements", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index 14280ec1c..253eea8d2 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -6,9 +6,9 @@ class IncomeStatementTest < ActiveSupport::TestCase setup do @family = families(:empty) - @income_category = @family.categories.create! name: "Income", classification: "income" - @food_category = @family.categories.create! name: "Food", classification: "expense" - @groceries_category = @family.categories.create! name: "Groceries", classification: "expense", parent: @food_category + @income_category = @family.categories.create! name: "Income" + @food_category = @family.categories.create! name: "Food" + @groceries_category = @family.categories.create! name: "Groceries", parent: @food_category @checking_account = @family.accounts.create! name: "Checking", currency: @family.currency, balance: 5000, accountable: Depository.new @credit_card_account = @family.accounts.create! name: "Credit Card", currency: @family.currency, balance: 1000, accountable: CreditCard.new @@ -114,7 +114,7 @@ class IncomeStatementTest < ActiveSupport::TestCase Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all # Create different amounts for groceries vs other food - other_food_category = @family.categories.create! name: "Restaurants", classification: "expense", parent: @food_category + other_food_category = @family.categories.create! name: "Restaurants", parent: @food_category # Groceries: 100, 300, 500 (median = 300) create_transaction(account: @checking_account, amount: 100, category: @groceries_category) @@ -497,6 +497,45 @@ class IncomeStatementTest < ActiveSupport::TestCase refute_includes tax_advantaged_ids, @credit_card_account.id end + # net_category_totals tests + test "net_category_totals nets expense and refund in the same category" do + Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all + + # $200 expense and $50 refund both on Food + create_transaction(account: @checking_account, amount: 200, category: @food_category) + create_transaction(account: @checking_account, amount: -50, category: @food_category) + + net = IncomeStatement.new(@family).net_category_totals(period: Period.last_30_days) + + assert_equal 150, net.total_net_expense + assert_equal 0, net.total_net_income + + food_net = net.net_expense_categories.find { |ct| ct.category.id == @food_category.id } + assert_not_nil food_net + assert_equal 150, food_net.total + assert_in_delta 100.0, food_net.weight, 0.1 + end + + test "net_category_totals places category on income side when refunds exceed expenses" do + Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all + + # $100 expense but $250 refund on Food => net income of 150 + create_transaction(account: @checking_account, amount: 100, category: @food_category) + create_transaction(account: @checking_account, amount: -250, category: @food_category) + + net = IncomeStatement.new(@family).net_category_totals(period: Period.last_30_days) + + assert_equal 0, net.total_net_expense + assert_equal 150, net.total_net_income + + food_net = net.net_income_categories.find { |ct| ct.category.id == @food_category.id } + assert_not_nil food_net + assert_equal 150, food_net.total + + # Should not appear on expense side + assert_nil net.net_expense_categories.find { |ct| ct.category.id == @food_category.id } + end + test "returns zero totals when family has only tax-advantaged accounts" do # Create a fresh family with ONLY tax-advantaged accounts family_only_retirement = Family.create!( diff --git a/test/models/plaid_account/transactions/category_matcher_test.rb b/test/models/plaid_account/transactions/category_matcher_test.rb index 35bcf8fe2..f01a62889 100644 --- a/test/models/plaid_account/transactions/category_matcher_test.rb +++ b/test/models/plaid_account/transactions/category_matcher_test.rb @@ -5,9 +5,9 @@ class PlaidAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase @family = families(:empty) # User income categories - @income = @family.categories.create!(name: "Income", classification: "income") - @dividend_income = @family.categories.create!(name: "Dividend Income", parent: @income, classification: "income") - @interest_income = @family.categories.create!(name: "Interest Income", parent: @income, classification: "income") + @income = @family.categories.create!(name: "Income") + @dividend_income = @family.categories.create!(name: "Dividend Income", parent: @income) + @interest_income = @family.categories.create!(name: "Interest Income", parent: @income) # User expense categories @loan_payments = @family.categories.create!(name: "Loan Payments") diff --git a/test/models/rule_import_test.rb b/test/models/rule_import_test.rb index 017194887..23cc246f8 100644 --- a/test/models/rule_import_test.rb +++ b/test/models/rule_import_test.rb @@ -6,7 +6,6 @@ class RuleImportTest < ActiveSupport::TestCase @category = @family.categories.create!( name: "Groceries", color: "#407706", - classification: "expense", lucide_icon: "shopping-basket" ) @csv = <<~CSV @@ -110,7 +109,6 @@ class RuleImportTest < ActiveSupport::TestCase new_category = Category.find_by!(family: @family, name: "Coffee Shops") assert_equal Category::UNCATEGORIZED_COLOR, new_category.color - assert_equal "expense", new_category.classification rule = Rule.find_by!(family: @family, name: "New category rule") action = rule.actions.first diff --git a/test/models/transaction/search_test.rb b/test/models/transaction/search_test.rb index c6d817ce1..1d7521c0e 100644 --- a/test/models/transaction/search_test.rb +++ b/test/models/transaction/search_test.rb @@ -152,8 +152,7 @@ class Transaction::SearchTest < ActiveSupport::TestCase # Create a travel category for testing travel_category = @family.categories.create!( name: "Travel", - color: "#3b82f6", - classification: "expense" + color: "#3b82f6" ) # Create transactions with different categories diff --git a/test/test_helper.rb b/test/test_helper.rb index afeab9f2e..5a227e964 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -87,7 +87,6 @@ module ActiveSupport family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| c.color = "#0d9488" c.lucide_icon = "trending-up" - c.classification = "expense" end end end