mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +00:00
Make categories global (#1160)
* Make categories global This solves us A LOT of cash flow and budgeting problems. * Update schema.rb * Update auto_categorizer.rb * Update income_statement.rb * FIX budget sub-categories * FIX sub-categories and tests * Add 2 step migration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@ class TransfersController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
@categories = Current.family.categories.expenses
|
||||
@categories = Current.family.categories.alphabetically
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= 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" } %>
|
||||
|
||||
@@ -22,13 +22,7 @@
|
||||
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||
<% if @categories.any? %>
|
||||
<div class="space-y-4">
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center items-center py-20">
|
||||
|
||||
@@ -76,4 +76,4 @@
|
||||
variant: :default,
|
||||
class: "font-medium text-sm text-primary hover:underline transition"
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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") %>">
|
||||
<p class="text-sm font-medium text-primary"><%= t(".moniker_prompt", product_name: product_name) %></p>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-primary">
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
class="bg-container rounded-xl shadow-border-xs transition-all group focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
|
||||
data-dashboard-sortable-target="section"
|
||||
data-section-key="<%= section[:key] %>"
|
||||
data-controller="dashboard-section<%= ' cashflow-expand' if section[:key] == 'cashflow_sankey' %>"
|
||||
data-controller="dashboard-section<%= " cashflow-expand" if section[:key] == "cashflow_sankey" %>"
|
||||
data-dashboard-section-section-key-value="<%= section[:key] %>"
|
||||
data-dashboard-section-collapsed-value="<%= Current.user.dashboard_section_collapsed?(section[:key]) %>"
|
||||
draggable="true"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% content_for :page_header do %>
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-semibold text-primary">Welcome!</h1>
|
||||
<br/>
|
||||
<br>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
placeholder: "Vacation home",
|
||||
required: true %>
|
||||
|
||||
|
||||
<%= form.hidden_field :accountable_type, value: "Property" %>
|
||||
|
||||
<%= form.fields_for :accountable do |property_form| %>
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
item: group,
|
||||
total: total,
|
||||
color_class: color_class,
|
||||
level: :category
|
||||
%>
|
||||
level: :category %>
|
||||
<% if idx < groups.size - 1 %>
|
||||
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
|
||||
<% end %>
|
||||
@@ -47,8 +46,7 @@
|
||||
item: subcategory,
|
||||
total: total,
|
||||
color_class: color_class,
|
||||
level: :subcategory
|
||||
%>
|
||||
level: :subcategory %>
|
||||
<% if sub_idx < group[:subcategories].size - 1 %>
|
||||
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
|
||||
<% end %>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="text-sm space-y-1">
|
||||
<% if @family.has_active_subscription? %>
|
||||
<p class="text-primary">
|
||||
<span>Currently on the <span class="font-medium"><%= @family.subscription.name %></span>.</span> <br />
|
||||
<span>Currently on the <span class="font-medium"><%= @family.subscription.name %></span>.</span> <br>
|
||||
|
||||
<% if @family.next_payment_date %>
|
||||
<% if @family.subscription_pending_cancellation? %>
|
||||
@@ -25,7 +25,7 @@
|
||||
</p>
|
||||
<% elsif @family.trialing? %>
|
||||
<p class="text-primary">
|
||||
Currently using the open demo of <%= product_name %> <br />
|
||||
Currently using the open demo of <%= product_name %> <br>
|
||||
<span class="text-secondary">
|
||||
(Data will be deleted in <%= @family.days_left_in_trial %> days)
|
||||
</span>
|
||||
|
||||
@@ -25,18 +25,18 @@
|
||||
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
|
||||
|
||||
<% if Current.family.trialing? %>
|
||||
<p class="text-xl lg:text-3xl text-primary font-display font-medium"><%= t('subscriptions.upgrade.trialing', days: Current.family.days_left_in_trial) %></p>
|
||||
<p class="text-xl lg:text-3xl text-primary font-display font-medium"><%= t("subscriptions.upgrade.trialing", days: Current.family.days_left_in_trial) %></p>
|
||||
<% else %>
|
||||
<p class="text-xl lg:text-3xl text-primary font-display font-medium"><%= t('subscriptions.upgrade.trial_over') %></p>
|
||||
<p class="text-xl lg:text-3xl text-primary font-display font-medium"><%= t("subscriptions.upgrade.trial_over") %></p>
|
||||
<% end %>
|
||||
|
||||
<h2 class="text-xl lg:text-3xl font-display font-medium mb-2">
|
||||
<span class="text-secondary"><%= t('subscriptions.upgrade.header.support') %></span>
|
||||
<span class="bg-gradient-to-r from-[#EABE7F] to-[#957049] bg-clip-text text-transparent"><%= t('subscriptions.upgrade.header.sure') %></span>
|
||||
<span class="text-secondary"><%= t('subscriptions.upgrade.header.today') %></span>
|
||||
<span class="text-secondary"><%= t("subscriptions.upgrade.header.support") %></span>
|
||||
<span class="bg-gradient-to-r from-[#EABE7F] to-[#957049] bg-clip-text text-transparent"><%= t("subscriptions.upgrade.header.sure") %></span>
|
||||
<span class="text-secondary"><%= t("subscriptions.upgrade.header.today") %></span>
|
||||
</h2>
|
||||
|
||||
<p class="text-sm text-secondary mb-8"><%= t('subscriptions.upgrade.cta') %></p>
|
||||
<p class="text-sm text-secondary mb-8"><%= t("subscriptions.upgrade.cta") %></p>
|
||||
|
||||
<%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %>
|
||||
<div class="space-y-4 mb-6">
|
||||
@@ -46,13 +46,13 @@
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
<%= render DS::Button.new(
|
||||
text: t('subscriptions.upgrade.contribute_and_support_sure'),
|
||||
text: t("subscriptions.upgrade.contribute_and_support_sure"),
|
||||
variant: "primary",
|
||||
full_width: true
|
||||
) %>
|
||||
|
||||
<p class="text-xs text-secondary">
|
||||
<%= t('subscriptions.upgrade.redirect_to_stripe') %>
|
||||
<%= t("subscriptions.upgrade.redirect_to_stripe") %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%# locals: (entry:, income_categories:, expense_categories:) %>
|
||||
<%# locals: (entry:, categories:) %>
|
||||
|
||||
<%= styled_form_with model: entry, url: transactions_path, class: "space-y-4" do |f| %>
|
||||
<% if entry.errors.any? %>
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
<%= f.money_field :amount, label: t(".amount"), required: true %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<% categories = params[:nature] == "inflow" ? income_categories : expense_categories %>
|
||||
<%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category"), variant: :badge, searchable: true } %>
|
||||
<% end %>
|
||||
<%= f.date_field :date, label: t(".date"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<%= icon "arrow-left-right", size: "sm", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% if entry.linked? %>
|
||||
<span title="<%= t('transactions.transaction.linked_with_plaid') %>" class="text-secondary">
|
||||
<span title="<%= t("transactions.transaction.linked_with_plaid") %>" class="text-secondary">
|
||||
<%= icon("refresh-ccw", size: "sm") %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
@@ -50,4 +50,4 @@
|
||||
<div class="pt-4">
|
||||
<%= render "shared/pagination", pagy: @pagy %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,4 +26,4 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render "recurring_transactions/empty" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
4
db/schema.rb
generated
4
db/schema.rb
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ",")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user