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
This commit is contained in:
eureka928
2026-01-29 13:49:49 +01:00
parent 4adc4199ee
commit 787e04f4fe
2 changed files with 94 additions and 2 deletions

View File

@@ -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

View File

@@ -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,