From 787e04f4fe2eea30c546fcb254ac8dbece5208cf Mon Sep 17 00:00:00 2001 From: eureka928 Date: Thu, 29 Jan 2026 13:49:49 +0100 Subject: [PATCH] Fix budget category totals to net refunds against expenses Budget spending calculations now subtract refunds (negative transactions classified as income) from expense totals in the same category. Previously, refunds were excluded entirely, causing budgets to show gross spending instead of net spending. Fixes #314 --- app/models/budget.rb | 14 ++++++- test/models/budget_test.rb | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index acdd128d1..3d71c859c 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -155,11 +155,13 @@ class Budget < ApplicationRecord end def actual_spending - expense_totals.total + [ expense_totals.total - refunds_in_expense_categories, 0 ].max end def budget_category_actual_spending(budget_category) - expense_totals.category_totals.find { |ct| ct.category.id == budget_category.category.id }&.total || 0 + expense = expense_totals.category_totals.find { |ct| ct.category.id == budget_category.category.id }&.total || 0 + refund = income_totals.category_totals.find { |ct| ct.category.id == budget_category.category.id }&.total || 0 + [ expense - refund, 0 ].max end def category_median_monthly_expense(category) @@ -235,6 +237,14 @@ 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) } + .sum(&:total) + end + def income_statement @income_statement ||= family.income_statement end diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index cff5cd6cc..2cadc6a40 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -75,6 +75,88 @@ class BudgetTest < ActiveSupport::TestCase assert_nil budget.previous_budget_param end + test "actual_spending nets refunds against expenses in same category" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + + healthcare = Category.create!( + name: "Healthcare #{Time.now.to_f}", + family: family, + color: "#e74c3c", + classification: "expense" + ) + + budget.sync_budget_categories + budget_category = budget.budget_categories.find_by(category: healthcare) + budget_category.update!(budgeted_spending: 200) + + account = accounts(:depository) + + # Create a $500 expense + Entry.create!( + account: account, + entryable: Transaction.create!(category: healthcare), + date: Date.current, + name: "Doctor visit", + amount: 500, + currency: "USD" + ) + + # Create a $200 refund (negative amount = income classification in the SQL) + Entry.create!( + account: account, + entryable: Transaction.create!(category: healthcare), + date: Date.current, + name: "Insurance reimbursement", + amount: -200, + currency: "USD" + ) + + # Clear memoized values + budget = Budget.find(budget.id) + budget.sync_budget_categories + + # Budget category should show net spending: $500 - $200 = $300 + assert_equal 300, budget.budget_category_actual_spending( + budget.budget_categories.find_by(category: healthcare) + ) + end + + test "budget_category_actual_spending does not go below zero" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + + category = Category.create!( + name: "Returns Only #{Time.now.to_f}", + family: family, + color: "#3498db", + classification: "expense" + ) + + budget.sync_budget_categories + budget_category = budget.budget_categories.find_by(category: category) + budget_category.update!(budgeted_spending: 100) + + account = accounts(:depository) + + # Only a refund, no expense + Entry.create!( + account: account, + entryable: Transaction.create!(category: category), + date: Date.current, + name: "Full refund", + amount: -50, + currency: "USD" + ) + + budget = Budget.find(budget.id) + budget.sync_budget_categories + + assert_equal 0, budget.budget_category_actual_spending( + budget.budget_categories.find_by(category: category) + ) + end + test "previous_budget_param returns param when date is valid" do budget = Budget.create!( family: @family,