diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index d4136d172..27d999703 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -65,15 +65,82 @@ class BudgetCategory < ApplicationRecord category.parent_id.present? end + # Returns true if this subcategory has no individual budget limit and should use parent's budget + def inherits_parent_budget? + subcategory? && (self[:budgeted_spending].nil? || self[:budgeted_spending] == 0) + end + + # Returns the budgeted spending to display in UI + # For inheriting subcategories, returns the parent's budget for reference + def display_budgeted_spending + if inherits_parent_budget? + parent = parent_budget_category + return 0 unless parent + parent[:budgeted_spending] || 0 + else + self[:budgeted_spending] || 0 + end + end + + # Returns the parent budget category if this is a subcategory + def parent_budget_category + return nil unless subcategory? + @parent_budget_category ||= budget.budget_categories.find { |bc| bc.category.id == category.parent_id } + end + def available_to_spend - (budgeted_spending || 0) - actual_spending + if inherits_parent_budget? + # Subcategories using parent budget share the parent's available_to_spend + parent = parent_budget_category + return 0 unless parent + parent.available_to_spend + elsif subcategory? + # Subcategory with individual limit + (self[:budgeted_spending] || 0) - actual_spending + else + # Parent category + parent_budget = self[:budgeted_spending] || 0 + + # Get subcategories with and without individual limits + subcategories_with_limits = subcategories.reject(&:inherits_parent_budget?) + + # Ring-fenced budgets for subcategories with individual limits + subcategories_individual_budgets = subcategories_with_limits.sum { |sc| sc[:budgeted_spending] || 0 } + + # Shared pool = parent budget - ring-fenced budgets + shared_pool = parent_budget - subcategories_individual_budgets + + # Get actual spending from income statement (includes all subcategories) + total_spending = actual_spending + + # Subtract spending from subcategories with individual budgets (they use their ring-fenced money) + subcategories_with_limits_spending = subcategories_with_limits.sum(&:actual_spending) + + # Spending from shared pool = total spending - ring-fenced spending + shared_pool_spending = total_spending - subcategories_with_limits_spending + + # Available in shared pool + shared_pool - shared_pool_spending + end end def percent_of_budget_spent - return 0 if budgeted_spending == 0 && actual_spending == 0 - return 0 if budgeted_spending > 0 && actual_spending == 0 - return 100 if budgeted_spending == 0 && actual_spending > 0 - (actual_spending.to_f / budgeted_spending) * 100 if budgeted_spending > 0 && actual_spending > 0 + if inherits_parent_budget? + # For subcategories using parent budget, show their spending as percentage of parent's budget + parent = parent_budget_category + return 0 unless parent + + parent_budget = parent[:budgeted_spending] || 0 + return 0 if parent_budget == 0 && actual_spending == 0 + return 100 if parent_budget == 0 && actual_spending > 0 + (actual_spending.to_f / parent_budget) * 100 + else + budget_amount = self[:budgeted_spending] || 0 + return 0 if budget_amount == 0 && actual_spending == 0 + return 0 if budget_amount > 0 && actual_spending == 0 + return 100 if budget_amount == 0 && actual_spending > 0 + (actual_spending.to_f / budget_amount) * 100 if budget_amount > 0 && actual_spending > 0 + end end def bar_width_percent @@ -128,8 +195,14 @@ class BudgetCategory < ApplicationRecord def max_allocation return nil unless subcategory? - parent_budget = budget.budget_categories.find { |bc| bc.category.id == category.parent_id }&.budgeted_spending - siblings_budget = siblings.sum(&:budgeted_spending) + parent_budget_cat = budget.budget_categories.find { |bc| bc.category.id == category.parent_id } + return nil unless parent_budget_cat + + parent_budget = parent_budget_cat[:budgeted_spending] || 0 + + # Sum budgets of siblings that have individual limits (excluding those that inherit) + siblings_with_limits = siblings.reject(&:inherits_parent_budget?) + siblings_budget = siblings_with_limits.sum { |s| s[:budgeted_spending] || 0 } [ parent_budget - siblings_budget, 0 ].max end diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index a241101d2..fc5829101 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -65,6 +65,9 @@ <%= t("reports.budget_performance.budgeted") %>: <%= format_money(budget_category.budgeted_spending_money) %> + <% if budget_category.inherits_parent_budget? %> + (<%= t("reports.budget_performance.shared") %>) + <% end %>
diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb index 0c4561cd2..f4fce9d86 100644 --- a/app/views/budget_categories/_budget_category_form.html.erb +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -18,12 +18,13 @@ <%= currency.symbol %> <%= f.number_field :budgeted_spending, class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", - placeholder: "0", + placeholder: budget_category.subcategory? ? "Shared" : "0", step: currency.step, id: dom_id(budget_category, :budgeted_spending), min: 0, max: budget_category.max_allocation, - data: { auto_submit_form_target: "auto" } %> + data: { auto_submit_form_target: "auto" }, + title: budget_category.subcategory? ? "Leave empty to share parent's budget" : nil %>
<% end %> diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml index 81bb96d91..fc1e85142 100644 --- a/config/locales/views/reports/en.yml +++ b/config/locales/views/reports/en.yml @@ -34,6 +34,7 @@ en: budgeted: Budgeted remaining: Remaining over_by: Over by + shared: shared suggested_daily: "%{amount} suggested per day for %{days} remaining days" no_budgets: No budget categories set up for this month status: diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb new file mode 100644 index 000000000..61335265e --- /dev/null +++ b/test/models/budget_category_test.rb @@ -0,0 +1,179 @@ +require "test_helper" + +class BudgetCategoryTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @budget = budgets(:one) + + # Create parent category with unique name + @parent_category = Category.create!( + name: "Test Food & Groceries #{Time.now.to_f}", + family: @family, + color: "#4da568", + lucide_icon: "utensils", + classification: "expense" + ) + + # Create subcategories with unique names + @subcategory_with_limit = Category.create!( + name: "Test Restaurants #{Time.now.to_f}", + parent: @parent_category, + family: @family, + classification: "expense" + ) + + @subcategory_inheriting = Category.create!( + name: "Test Groceries #{Time.now.to_f}", + parent: @parent_category, + family: @family, + classification: "expense" + ) + + # Create budget categories + @parent_budget_category = BudgetCategory.create!( + budget: @budget, + category: @parent_category, + budgeted_spending: 1000, + currency: "USD" + ) + + @subcategory_with_limit_bc = BudgetCategory.create!( + budget: @budget, + category: @subcategory_with_limit, + budgeted_spending: 300, + currency: "USD" + ) + + @subcategory_inheriting_bc = BudgetCategory.create!( + budget: @budget, + category: @subcategory_inheriting, + budgeted_spending: 0, # Inherits from parent + currency: "USD" + ) + end + + test "subcategory with zero budget inherits from parent" do + assert @subcategory_inheriting_bc.inherits_parent_budget? + refute @subcategory_with_limit_bc.inherits_parent_budget? + refute @parent_budget_category.inherits_parent_budget? + end + + test "parent_budget_category returns parent for subcategories" do + assert_equal @parent_budget_category, @subcategory_inheriting_bc.parent_budget_category + assert_equal @parent_budget_category, @subcategory_with_limit_bc.parent_budget_category + assert_nil @parent_budget_category.parent_budget_category + end + + test "display_budgeted_spending shows parent budget for inheriting subcategories" do + assert_equal 1000, @subcategory_inheriting_bc.display_budgeted_spending + assert_equal 300, @subcategory_with_limit_bc.display_budgeted_spending + assert_equal 1000, @parent_budget_category.display_budgeted_spending + end + + test "inheriting subcategory shares parent available_to_spend" do + # Mock the actual spending values + # Parent's actual_spending from income_statement includes all children + @budget.stubs(:budget_category_actual_spending).with(@parent_budget_category).returns(150) + @budget.stubs(:budget_category_actual_spending).with(@subcategory_with_limit_bc).returns(100) + @budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(50) + + # Parent available calculation: + # shared_pool = 1000 (parent budget) - 300 (subcategory with limit budget) = 700 + # shared_pool_spending = 150 (total) - 100 (subcategory with limit spending) = 50 + # available = 700 - 50 = 650 + assert_equal 650, @parent_budget_category.available_to_spend + + # Inheriting subcategory shares parent's available (650) + assert_equal 650, @subcategory_inheriting_bc.available_to_spend + + # Subcategory with limit: 300 (its budget) - 100 (its spending) = 200 + assert_equal 200, @subcategory_with_limit_bc.available_to_spend + end + + test "max_allocation excludes budgets of inheriting siblings" do + # Create another inheriting subcategory + another_inheriting = Category.create!( + name: "Test Coffee #{Time.now.to_f}", + parent: @parent_category, + family: @family, + classification: "expense" + ) + + another_inheriting_bc = BudgetCategory.create!( + budget: @budget, + category: another_inheriting, + budgeted_spending: 0, # Inherits + currency: "USD" + ) + + # Max allocation for new subcategory should only account for the one with explicit limit (300) + # 1000 (parent) - 300 (subcategory_with_limit) = 700 + assert_equal 700, another_inheriting_bc.max_allocation + + # If we add a new subcategory with a limit + new_subcategory_cat = Category.create!( + name: "Test Fast Food #{Time.now.to_f}", + parent: @parent_category, + family: @family, + classification: "expense" + ) + + new_subcategory_bc = BudgetCategory.create!( + budget: @budget, + category: new_subcategory_cat, + budgeted_spending: 0, + currency: "USD" + ) + + # Max should still be 700 because both inheriting subcategories don't count + assert_equal 700, new_subcategory_bc.max_allocation + end + + test "percent_of_budget_spent for inheriting subcategory uses parent budget" do + # Mock spending + @budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(100) + + # 100 / 1000 (parent budget) = 10% + assert_equal 10.0, @subcategory_inheriting_bc.percent_of_budget_spent + end + + test "parent with no subcategories works as before" do + # Create a standalone parent category without subcategories + standalone_category = Category.create!( + name: "Test Entertainment #{Time.now.to_f}", + family: @family, + color: "#a855f7", + lucide_icon: "drama", + classification: "expense" + ) + + standalone_bc = BudgetCategory.create!( + budget: @budget, + category: standalone_category, + budgeted_spending: 500, + currency: "USD" + ) + + # Mock spending + @budget.stubs(:budget_category_actual_spending).with(standalone_bc).returns(200) + + # Should work exactly as before: 500 - 200 = 300 + assert_equal 300, standalone_bc.available_to_spend + assert_equal 40.0, standalone_bc.percent_of_budget_spent + end + + test "parent with only inheriting subcategories shares entire budget" do + # Set subcategory_with_limit to also inherit + @subcategory_with_limit_bc.update!(budgeted_spending: 0) + + # Mock spending + @budget.stubs(:budget_category_actual_spending).with(@parent_budget_category).returns(200) + @budget.stubs(:budget_category_actual_spending).with(@subcategory_with_limit_bc).returns(100) + @budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(100) + + # All should show same available: 1000 - 200 = 800 + assert_equal 800, @parent_budget_category.available_to_spend + assert_equal 800, @subcategory_with_limit_bc.available_to_spend + assert_equal 800, @subcategory_inheriting_bc.available_to_spend + end +end