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