diff --git a/app/helpers/budgets_helper.rb b/app/helpers/budgets_helper.rb
new file mode 100644
index 000000000..8ffb27ad8
--- /dev/null
+++ b/app/helpers/budgets_helper.rb
@@ -0,0 +1,70 @@
+module BudgetsHelper
+ def budget_has_over_budget?(budget)
+ return false unless budget.initialized?
+
+ budget.budget_categories.any?(&:any_over_budget?)
+ end
+
+ def budget_categories_view_state(budget)
+ @budget_categories_view_state ||= {}
+ @budget_categories_view_state[budget.object_id] ||= build_budget_categories_view_state(budget)
+ end
+
+ private
+
+ def build_budget_categories_view_state(budget)
+ uncategorized_budget_category = budget.uncategorized_budget_category
+ all_category_groups = BudgetCategory::Group.for(budget.budget_categories)
+
+ over_budget_groups = if budget.initialized?
+ filtered_groups_for(all_category_groups) { |budget_category| budget_category.any_over_budget? }
+ else
+ []
+ end
+
+ show_over_budget_uncategorized = budget.initialized? && uncategorized_budget_category.any_over_budget?
+ over_budget_count = visible_count_for(over_budget_groups) { |budget_category| budget_category.any_over_budget? }
+ over_budget_count += 1 if show_over_budget_uncategorized
+
+ on_track_groups = if budget.initialized?
+ filtered_groups_for(all_category_groups) { |budget_category| budget_category.visible_on_track? }
+ else
+ all_category_groups
+ end
+
+ show_on_track_uncategorized = all_category_groups.any? && (!budget.initialized? || uncategorized_budget_category.on_track?)
+ on_track_count = visible_count_for(on_track_groups) { |budget_category| parent_visible_for_on_track?(budget, budget_category) }
+ on_track_count += 1 if show_on_track_uncategorized
+ visible_expenses_empty = on_track_count.zero?
+
+ {
+ uncategorized_budget_category: uncategorized_budget_category,
+ visible_expenses_empty: visible_expenses_empty,
+ over_budget_groups: over_budget_groups,
+ show_over_budget_uncategorized: show_over_budget_uncategorized,
+ over_budget_count: over_budget_count,
+ on_track_groups: on_track_groups,
+ show_on_track_uncategorized: show_on_track_uncategorized,
+ on_track_count: on_track_count
+ }
+ end
+
+ def parent_visible_for_on_track?(budget, budget_category)
+ budget.initialized? ? budget_category.visible_on_track? : true
+ end
+
+ def filtered_groups_for(groups)
+ groups.each_with_object([]) do |group, filtered_groups|
+ visible_subcategories = group.budget_subcategories.select { |budget_category| yield(budget_category) }
+ next unless yield(group.budget_category) || visible_subcategories.any?
+
+ filtered_groups << BudgetCategory::Group.new(group.budget_category, visible_subcategories)
+ end
+ end
+
+ def visible_count_for(groups)
+ groups.sum do |group|
+ (yield(group.budget_category) ? 1 : 0) + group.budget_subcategories.count
+ end
+ end
+end
diff --git a/app/javascript/controllers/budget_filter_controller.js b/app/javascript/controllers/budget_filter_controller.js
new file mode 100644
index 000000000..a6337c9fe
--- /dev/null
+++ b/app/javascript/controllers/budget_filter_controller.js
@@ -0,0 +1,57 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["onTrack", "overBudget", "tab"];
+ static values = { filter: { type: String, default: "all" } };
+
+ connect() {
+ const filterParam = new URLSearchParams(window.location.search).get("filter");
+
+ if (this.#isValidFilter(filterParam) && filterParam !== this.filterValue) {
+ this.filterValue = filterParam;
+ } else if (filterParam && !this.#isValidFilter(filterParam)) {
+ this.#syncFilterParam();
+ }
+ }
+
+ setFilter(event) {
+ this.filterValue = event.params.filter;
+ this.#syncFilterParam();
+ }
+
+ filterValueChanged() {
+ const filter = this.filterValue;
+
+ if (this.hasOnTrackTarget) {
+ this.onTrackTarget.hidden = filter === "over_budget";
+ }
+
+ if (this.hasOverBudgetTarget) {
+ this.overBudgetTarget.hidden = filter === "on_track";
+ }
+
+ this.tabTargets.forEach((tab) => {
+ const isActive = tab.dataset.budgetFilterFilterParam === filter;
+ tab.classList.toggle("bg-container", isActive);
+ tab.classList.toggle("text-primary", isActive);
+ tab.classList.toggle("shadow-sm", isActive);
+ tab.classList.toggle("text-secondary", !isActive);
+ });
+ }
+
+ #isValidFilter(filter) {
+ return ["all", "over_budget", "on_track"].includes(filter);
+ }
+
+ #syncFilterParam() {
+ const url = new URL(window.location.href);
+
+ if (this.filterValue === "all") {
+ url.searchParams.delete("filter");
+ } else {
+ url.searchParams.set("filter", this.filterValue);
+ }
+
+ window.history.replaceState({}, "", url);
+ }
+}
diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb
index 764e3d744..312db666a 100644
--- a/app/models/budget_category.rb
+++ b/app/models/budget_category.rb
@@ -162,6 +162,35 @@ class BudgetCategory < ApplicationRecord
available_to_spend.negative?
end
+ def budgeted?
+ display_budgeted_spending.to_d.positive?
+ end
+
+ def unbudgeted_with_spending?
+ !budgeted? && actual_spending.to_d.positive?
+ end
+
+ def over_budget_with_budget?
+ budgeted? && over_budget?
+ end
+
+ def on_track?
+ budgeted? && !over_budget?
+ end
+
+ def any_over_budget?
+ unbudgeted_with_spending? || over_budget_with_budget?
+ end
+
+ def visible_on_track?
+ return false unless on_track?
+
+ # Subcategories inheriting parent budget are hidden until they have spending.
+ return true unless subcategory? && inherits_parent_budget?
+
+ actual_spending.to_d.positive?
+ end
+
def near_limit?
!over_budget? && percent_of_budget_spent >= 90
end
diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb
index 8597a1b93..fc93282af 100644
--- a/app/views/budget_categories/_budget_category.html.erb
+++ b/app/views/budget_categories/_budget_category.html.erb
@@ -1,4 +1,4 @@
-<%# locals: (budget_category:) %>
+<%# locals: (budget_category:, show_budget_meta: true) %>
<%= turbo_frame_tag dom_id(budget_category), class: "flex-1 min-w-0 block" do %>
<%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group block w-full px-4 py-2 bg-container", data: { turbo_frame: "drawer" } do %>
@@ -49,7 +49,7 @@
<% bar_color = budget_category.over_budget? ? "bg-red-500" : (budget_category.near_limit? ? "bg-yellow-500" : "bg-green-500") %>
+ style="inline-size: <%= budget_category.bar_width_percent %>%">
@@ -61,6 +61,7 @@
<%= format_money(budget_category.actual_spending_money) %>
+ <% if show_budget_meta %>
<%= t("reports.budget_performance.budgeted") %>:
@@ -70,6 +71,16 @@
<%= t("reports.budget_performance.shared") %>
<% end %>
+ <% if budget_category.suggested_daily_spending.present? %>
+ <% daily_info = budget_category.suggested_daily_spending %>
+
+ <%= t("reports.budget_performance.suggested_daily",
+ amount: daily_info[:amount].format,
+ days: daily_info[:days_remaining])
+ %>
+
+ <% end %>
+ <% end %>
<% if budget_category.available_to_spend >= 0 %>
<%= t("reports.budget_performance.remaining") %>:
@@ -85,18 +96,6 @@
- <%# Suggested Daily Limit (if remaining days in month) %>
- <% if budget_category.suggested_daily_spending.present? %>
- <% daily_info = budget_category.suggested_daily_spending %>
-
-
- <%= t("reports.budget_performance.suggested_daily",
- amount: daily_info[:amount].format,
- days: daily_info[:days_remaining]) %>
-
-
- <% end %>
-
<% else %>
<%# Uninitialized budget - show simple view %>
diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb
index 46824bac3..addb349f3 100644
--- a/app/views/budgets/_budget_categories.html.erb
+++ b/app/views/budgets/_budget_categories.html.erb
@@ -1,44 +1,34 @@
<%# locals: (budget:) %>
-
-
-
Categories
-
·
-
<%= budget.budget_categories.count %>
+<% categories_state = budget_categories_view_state(budget) %>
+<% uncategorized_budget_category = categories_state[:uncategorized_budget_category] %>
+<% visible_expenses_empty = categories_state[:visible_expenses_empty] %>
+<% over_budget_groups = categories_state[:over_budget_groups] %>
+<% show_over_budget_uncategorized = categories_state[:show_over_budget_uncategorized] %>
+<% over_budget_count = categories_state[:over_budget_count] %>
+<% on_track_groups = categories_state[:on_track_groups] %>
+<% show_on_track_uncategorized = categories_state[:show_on_track_uncategorized] %>
+<% on_track_count = categories_state[:on_track_count] %>
-
Amount
-
+
-
- <% if budget.family.categories.expenses.empty? %>
-
- <%= render "budget_categories/no_categories" %>
-
- <% else %>
- <% category_groups = BudgetCategory::Group.for(budget.budget_categories) %>
+ <% if over_budget_count.positive? %>
+ <%= render "budgets/category_section",
+ budget: budget,
+ count: over_budget_count,
+ groups: over_budget_groups,
+ uncategorized: uncategorized_budget_category,
+ show_uncategorized: show_over_budget_uncategorized,
+ over_budget_mode: true %>
+ <% end %>
- <% category_groups.each_with_index do |group, index| %>
-
- <%= render "budget_categories/budget_category", budget_category: group.budget_category %>
+ <%= render "budgets/category_section",
+ budget: budget,
+ count: on_track_count,
+ groups: on_track_groups,
+ uncategorized: uncategorized_budget_category,
+ show_uncategorized: show_on_track_uncategorized,
+ over_budget_mode: false
+ %>
-
- <% group.budget_subcategories.each do |budget_subcategory| %>
-
-
- <%= icon "corner-down-right" %>
-
-
- <%= render "budget_categories/budget_category", budget_category: budget_subcategory %>
-
- <% end %>
-
-
-
- <%= render "shared/ruler" %>
- <% end %>
-
- <%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %>
-
- <% end %>
-
-
+
\ No newline at end of file
diff --git a/app/views/budgets/_category_group.html.erb b/app/views/budgets/_category_group.html.erb
new file mode 100644
index 000000000..7ef0ad347
--- /dev/null
+++ b/app/views/budgets/_category_group.html.erb
@@ -0,0 +1,28 @@
+<%# locals: (group:, parent_visible:, over_budget_mode: false) %>
+
+<% if parent_visible %>
+
+ <%= render "budget_categories/budget_category",
+ budget_category: group.budget_category,
+ show_budget_meta: (over_budget_mode ? group.budget_category.over_budget_with_budget? : true) %>
+
+<% end %>
+
+<% group.budget_subcategories.each do |budget_subcategory| %>
+ <% if parent_visible %>
+
+
+ <%= icon "corner-down-right" %>
+
+ <%= render "budget_categories/budget_category",
+ budget_category: budget_subcategory,
+ show_budget_meta: (over_budget_mode ? budget_subcategory.over_budget_with_budget? : true) %>
+
+ <% else %>
+
+ <%= render "budget_categories/budget_category",
+ budget_category: budget_subcategory,
+ show_budget_meta: (over_budget_mode ? budget_subcategory.over_budget_with_budget? : true) %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/budgets/_category_section.html.erb b/app/views/budgets/_category_section.html.erb
new file mode 100644
index 000000000..ee6602a3a
--- /dev/null
+++ b/app/views/budgets/_category_section.html.erb
@@ -0,0 +1,56 @@
+<%# locals: (budget:, count:, groups:, uncategorized:, show_uncategorized:, over_budget_mode:) %>
+
+<%# derive display config from over_budget_mode %>
+<%
+ if over_budget_mode
+ target = "overBudget"
+ title = t("budgets.show.over_budget_categories.short_title")
+ else
+ target = "onTrack"
+ title = t("budgets.show.on_track_categories.short_title")
+ end
+%>
+
+
+
+
+
+
<%= title %>
+
·
+
<%= count %>
+
<%= t("budgets.show.categories.amount") %>
+
+
+
+
+
+ <% groups.each_with_index do |group, index| %>
+
+ <%# derive parent visibility based on mode %>
+ <%
+ parent_visible =
+ if over_budget_mode
+ group.budget_category.any_over_budget?
+ else
+ budget.initialized? ? group.budget_category.visible_on_track? : true
+ end
+ %>
+ <%= render "shared/ruler" unless index == 0 %>
+ <%= render "budgets/category_group",
+ group: group,
+ parent_visible: parent_visible,
+ over_budget_mode: over_budget_mode %>
+
+ <% end %>
+
+ <% if show_uncategorized %>
+ <%= render "shared/ruler" unless groups.size == 0 %>
+
+ <%= render "budget_categories/budget_category",
+ budget_category: uncategorized,
+ show_budget_meta: (over_budget_mode ? uncategorized.over_budget_with_budget? : true) %>
+
+ <% end %>
+
+
+
\ No newline at end of file
diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb
index 768cf7727..ce7e21b0b 100644
--- a/app/views/budgets/show.html.erb
+++ b/app/views/budgets/show.html.erb
@@ -47,23 +47,66 @@
<%# Bottom Section: Categories full width %>
-
-
-
Categories
+ <% has_over_budget = budget_has_over_budget?(@budget) %>
+ <%= content_tag :div,
+ class: "w-full bg-container rounded-xl shadow-border-xs p-4",
+ data: (has_over_budget ? { controller: "budget-filter" } : {}) do %>
+
+
+ <%= t("budgets.show.categories.title") %>
+
- <% if @budget.initialized? %>
- <%= render DS::Link.new(
- text: "Edit",
- variant: "secondary",
- icon: "settings-2",
- href: budget_budget_categories_path(@budget)
- ) %>
+ <% if has_over_budget %>
+
<% end %>
+
+
">
+ <% if @budget.initialized? %>
+ <%= render DS::Link.new(
+ text: t("budgets.show.categories.edit"),
+ variant: "secondary",
+ icon: "settings-2",
+ href: budget_budget_categories_path(@budget)
+ ) %>
+ <% end %>
+
<%= render "budgets/budget_categories", budget: @budget %>
-
+ <% end %>
diff --git a/config/locales/views/budgets/ca.yml b/config/locales/views/budgets/ca.yml
index dd581e49c..6f3d53e3d 100644
--- a/config/locales/views/budgets/ca.yml
+++ b/config/locales/views/budgets/ca.yml
@@ -2,6 +2,20 @@
ca:
budgets:
show:
+ categories:
+ amount: Quantitat
+ edit: Edita
+ title: Categories
+ on_track_categories:
+ short_title: En bon cami
+ title: En bon cami
+ over_budget_categories:
+ short_title: Sobre pressupost
+ title: Sobre pressupost
+ filter:
+ all: Totes
+ on_track: En bon cami
+ over_budget: Sobre pressupost
tabs:
actual: Real
budgeted: Pressupostat
diff --git a/config/locales/views/budgets/de.yml b/config/locales/views/budgets/de.yml
index 00ac0252b..c02980333 100644
--- a/config/locales/views/budgets/de.yml
+++ b/config/locales/views/budgets/de.yml
@@ -5,6 +5,20 @@ de:
custom_range: "%{start} - %{end_date}"
month_year: "%{month}"
show:
+ categories:
+ amount: Betrag
+ edit: Bearbeiten
+ title: Kategorien
+ on_track_categories:
+ short_title: Im Plan
+ title: Im Plan
+ over_budget_categories:
+ short_title: Uber Budget
+ title: Uber Budget
+ filter:
+ all: Alle
+ on_track: Im Plan
+ over_budget: Uber Budget
tabs:
actual: Ist
budgeted: Budgetiert
diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml
index c727dc37c..fb06af681 100644
--- a/config/locales/views/budgets/en.yml
+++ b/config/locales/views/budgets/en.yml
@@ -5,6 +5,20 @@ en:
custom_range: "%{start} - %{end_date}"
month_year: "%{month}"
show:
+ categories:
+ amount: Amount
+ edit: Edit
+ title: Categories
+ on_track_categories:
+ short_title: On Track
+ title: On Track
+ over_budget_categories:
+ short_title: Over Budget
+ title: Over Budget
+ filter:
+ all: All
+ on_track: On Track
+ over_budget: Over Budget
tabs:
actual: Actual
budgeted: Budgeted
diff --git a/config/locales/views/budgets/es.yml b/config/locales/views/budgets/es.yml
index 87bc1649b..4f87cff2c 100644
--- a/config/locales/views/budgets/es.yml
+++ b/config/locales/views/budgets/es.yml
@@ -5,6 +5,20 @@ es:
custom_range: "%{start} - %{end_date}"
month_year: "%{month}"
show:
+ categories:
+ amount: Importe
+ edit: Editar
+ title: Categorías
+ on_track_categories:
+ short_title: En camino
+ title: En camino
+ over_budget_categories:
+ short_title: Sobre presupuesto
+ title: Sobre presupuesto
+ filter:
+ all: Todas
+ on_track: En camino
+ over_budget: Sobre presupuesto
tabs:
actual: Real
budgeted: Presupuestado
\ No newline at end of file
diff --git a/config/locales/views/budgets/nl.yml b/config/locales/views/budgets/nl.yml
index f7ba1833d..64265428d 100644
--- a/config/locales/views/budgets/nl.yml
+++ b/config/locales/views/budgets/nl.yml
@@ -1,6 +1,20 @@
nl:
budgets:
show:
+ categories:
+ amount: Bedrag
+ edit: Bewerken
+ title: Categorieën
+ on_track_categories:
+ short_title: Op schema
+ title: Op schema
+ over_budget_categories:
+ short_title: Over budget
+ title: Over budget
+ filter:
+ all: Alles
+ on_track: Op schema
+ over_budget: Over budget
tabs:
actual: "Actueel"
budgeted: "Begroot"
diff --git a/config/locales/views/budgets/zh-CN.yml b/config/locales/views/budgets/zh-CN.yml
index 37115d450..e111d4d91 100644
--- a/config/locales/views/budgets/zh-CN.yml
+++ b/config/locales/views/budgets/zh-CN.yml
@@ -2,6 +2,20 @@
zh-CN:
budgets:
show:
+ categories:
+ amount: 金额
+ edit: 编辑
+ title: 分类
+ on_track_categories:
+ short_title: 正常
+ title: 正常
+ over_budget_categories:
+ short_title: 超预算
+ title: 超预算
+ filter:
+ all: 全部
+ on_track: 正常
+ over_budget: 超预算
tabs:
actual: 实际
budgeted: 预算
diff --git a/config/locales/views/budgets/zh-TW.yml b/config/locales/views/budgets/zh-TW.yml
index e35d802ce..bca3d0cb9 100644
--- a/config/locales/views/budgets/zh-TW.yml
+++ b/config/locales/views/budgets/zh-TW.yml
@@ -2,6 +2,20 @@
zh-TW:
budgets:
show:
+ categories:
+ amount: 金額
+ edit: 編輯
+ title: 分類
+ on_track_categories:
+ short_title: 正常
+ title: 正常
+ over_budget_categories:
+ short_title: 超預算
+ title: 超預算
+ filter:
+ all: 全部
+ on_track: 正常
+ over_budget: 超預算
tabs:
actual: 實際
budgeted: 預算
diff --git a/test/helpers/budgets_helper_test.rb b/test/helpers/budgets_helper_test.rb
new file mode 100644
index 000000000..4c94f627e
--- /dev/null
+++ b/test/helpers/budgets_helper_test.rb
@@ -0,0 +1,107 @@
+require "test_helper"
+
+class BudgetsHelperTest < ActionView::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @budget = budgets(:one)
+
+ @parent_category = Category.create!(
+ name: "Helper Parent #{SecureRandom.hex(4)}",
+ family: @family,
+ color: "#4da568",
+ lucide_icon: "utensils"
+ )
+
+ @child_category = Category.create!(
+ name: "Helper Child #{SecureRandom.hex(4)}",
+ parent: @parent_category,
+ family: @family
+ )
+
+ @parent_budget_category = BudgetCategory.create!(
+ budget: @budget,
+ category: @parent_category,
+ budgeted_spending: 200,
+ currency: "USD"
+ )
+
+ @child_budget_category = BudgetCategory.create!(
+ budget: @budget,
+ category: @child_category,
+ budgeted_spending: 0,
+ currency: "USD"
+ )
+ end
+
+ test "hides inheriting subcategory with no budget and no spending from on-track section" do
+ state = budget_categories_view_state(@budget)
+ group = state[:on_track_groups].find { |g| g.budget_category.id == @parent_budget_category.id }
+
+ assert group.present?
+ assert_empty group.budget_subcategories
+ end
+
+ test "shows inheriting subcategory in on-track section when it has spending" do
+ Entry.create!(
+ account: accounts(:depository),
+ entryable: Transaction.create!(category: @child_category),
+ date: Date.current,
+ name: "Helper Child Spending",
+ amount: 25,
+ currency: "USD"
+ )
+
+ budget = Budget.find(@budget.id)
+ state = budget_categories_view_state(budget)
+ group = state[:on_track_groups].find { |g| g.budget_category.category_id == @parent_category.id }
+
+ assert group.present?
+ assert_includes group.budget_subcategories.map(&:category_id), @child_category.id
+ end
+
+ test "keeps group when only subcategory is over budget" do
+ parent = Category.create!(
+ name: "Helper Group Parent #{SecureRandom.hex(4)}",
+ family: @family,
+ color: "#22c55e",
+ lucide_icon: "utensils"
+ )
+
+ child = Category.create!(
+ name: "Helper Group Child #{SecureRandom.hex(4)}",
+ parent: parent,
+ family: @family
+ )
+
+ BudgetCategory.create!(
+ budget: @budget,
+ category: parent,
+ budgeted_spending: 300,
+ currency: "USD"
+ )
+
+ BudgetCategory.create!(
+ budget: @budget,
+ category: child,
+ budgeted_spending: 50,
+ currency: "USD"
+ )
+
+ Entry.create!(
+ account: accounts(:depository),
+ entryable: Transaction.create!(category: child),
+ date: Date.current,
+ name: "Helper Child Over Budget",
+ amount: 100,
+ currency: "USD"
+ )
+
+ state = budget_categories_view_state(Budget.find(@budget.id))
+ group = state[:over_budget_groups].find { |g| g.budget_category.category_id == parent.id }
+
+ assert group.present?
+ refute group.budget_category.any_over_budget?
+ assert_equal [ child.id ], group.budget_subcategories.map(&:category_id)
+ assert group.budget_subcategories.first.any_over_budget?
+ end
+end
diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb
index d0f0b71a6..d6408c530 100644
--- a/test/models/budget_category_test.rb
+++ b/test/models/budget_category_test.rb
@@ -169,4 +169,90 @@ class BudgetCategoryTest < ActiveSupport::TestCase
assert_equal 20, @subcategory_with_limit_bc.reload.budgeted_spending
assert_equal 50, @subcategory_inheriting_bc.reload.budgeted_spending
end
+
+ test "budgeted? returns true only when display_budgeted_spending > 0" do
+ @subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(100)
+ assert @subcategory_with_limit_bc.budgeted?
+
+ @subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(0)
+ refute @subcategory_with_limit_bc.budgeted?
+
+ @subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(nil)
+ refute @subcategory_with_limit_bc.budgeted?
+ end
+
+ test "unbudgeted_with_spending? is true only when not budgeted and has spending" do
+ @subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
+ @subcategory_with_limit_bc.stubs(:actual_spending).returns(10)
+ assert @subcategory_with_limit_bc.unbudgeted_with_spending?
+
+ @subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
+ assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
+
+ @subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
+ @subcategory_with_limit_bc.stubs(:actual_spending).returns(0)
+ assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
+
+ @subcategory_with_limit_bc.stubs(:actual_spending).returns(nil)
+ assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
+ end
+
+ test "over_budget_with_budget? requires both budgeted and over_budget" do
+ @subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
+ @subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
+ assert @subcategory_with_limit_bc.over_budget_with_budget?
+
+ @subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
+ assert_not @subcategory_with_limit_bc.over_budget_with_budget?
+
+ @subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
+ @subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
+ assert_not @subcategory_with_limit_bc.over_budget_with_budget?
+ end
+
+ test "on_track? is true only when budgeted and not over_budget" do
+ @subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
+ @subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
+ assert @subcategory_with_limit_bc.on_track?
+
+ @subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
+ assert_not @subcategory_with_limit_bc.on_track?
+
+ @subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
+ @subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
+ assert_not @subcategory_with_limit_bc.on_track?
+ end
+
+ test "any_over_budget? is true if either condition is true" do
+ @subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(true)
+ @subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false)
+ assert @subcategory_with_limit_bc.any_over_budget?
+
+ @subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false)
+ @subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(true)
+ assert @subcategory_with_limit_bc.any_over_budget?
+
+ @subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false)
+ @subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false)
+ assert_not @subcategory_with_limit_bc.any_over_budget?
+ end
+
+ test "visible_on_track? behavior for different category types" do
+ # 1. not on_track => always false
+ @subcategory_with_limit_bc.stubs(:on_track?).returns(false)
+ assert_not @subcategory_with_limit_bc.visible_on_track?
+
+ # 2. normal category (not subcategory) => true if on_track
+ @parent_budget_category.stubs(:on_track?).returns(true)
+ assert @parent_budget_category.visible_on_track?
+
+ # 3. subcategory inheriting, no spending => hidden
+ @subcategory_inheriting_bc.stubs(:on_track?).returns(true)
+ @subcategory_inheriting_bc.stubs(:actual_spending).returns(0)
+ assert_not @subcategory_inheriting_bc.visible_on_track?
+
+ # 4. subcategory inheriting, has spending => visible
+ @subcategory_inheriting_bc.stubs(:actual_spending).returns(10)
+ assert @subcategory_inheriting_bc.visible_on_track?
+ end
end