mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 20:14:08 +00:00
Allow subcategories to inherit parent budget without individual limits (#579)
* Initial plan * Implement subcategory budget inheritance feature Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Fix available_to_spend calculation for parent categories with mixed subcategories Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Optimize budget category calculations to avoid redundant filtering Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Add documentation for subcategory budget inheritance feature Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Add edge case tests for budget inheritance feature Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Fix linting issues - remove trailing whitespace Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Replace hardcoded '(shared)' label with i18n translation Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Fix test failures due to duplicate category names Use unique names with timestamps to avoid validation errors when creating test categories. The Category model has a uniqueness validation on name scoped to family_id, and the test fixtures already contain categories like "Restaurants" which were causing conflicts. Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Remove LLM `.md` spec file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
<span class="text-sm text-secondary"><%= t("reports.budget_performance.budgeted") %>:</span>
|
||||
<span class="font-medium text-primary">
|
||||
<%= format_money(budget_category.budgeted_spending_money) %>
|
||||
<% if budget_category.inherits_parent_budget? %>
|
||||
<span class="text-xs text-subdued">(<%= t("reports.budget_performance.shared") %>)</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap w-full sm:w-auto lg:ml-auto">
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
<span class="text-secondary text-sm mr-2"><%= currency.symbol %></span>
|
||||
<%= 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 %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
Reference in New Issue
Block a user