Fix budget category totals to account for refunds and reimbursements (#824)

* 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

* Handle missing git binary in commit_sha initializer

Rescues Errno::ENOENT when git is not installed, falling back to
BUILD_COMMIT_SHA env var or "unknown". Fixes crash in Docker
development containers that lack git.

* Revert "Handle missing git binary in commit_sha initializer"

This reverts commit 7e58458faa.

* Subtract uncategorized refunds from overall budget spending

Uncategorized refunds were not being netted against actual_spending
because the synthetic uncategorized category has no persisted ID and
wasn't matched by the budget_categories ID set. Now checks for
category.uncategorized? in addition to the ID lookup.

* perf: optimize budget category actual spending calculation
This commit is contained in:
Dream
2026-01-31 03:41:28 -05:00
committed by GitHub
parent 6f8858b1a6
commit 5d06112281
2 changed files with 145 additions and 2 deletions

View File

@@ -75,6 +75,130 @@ 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 "actual_spending subtracts uncategorized refunds" do
family = families(:dylan_family)
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
account = accounts(:depository)
# Create an uncategorized expense
Entry.create!(
account: account,
entryable: Transaction.create!(category: nil),
date: Date.current,
name: "Uncategorized purchase",
amount: 400,
currency: "USD"
)
# Create an uncategorized refund
Entry.create!(
account: account,
entryable: Transaction.create!(category: nil),
date: Date.current,
name: "Uncategorized refund",
amount: -150,
currency: "USD"
)
budget = Budget.find(budget.id)
budget.sync_budget_categories
# The uncategorized refund should reduce overall actual_spending
# Other fixtures may contribute spending, so check that the net
# uncategorized amount (400 - 150 = 250) is reflected by comparing
# with and without the refund rather than asserting an exact total.
spending_with_refund = budget.actual_spending
# Remove the refund and check spending increases
Entry.find_by(name: "Uncategorized refund").destroy!
budget = Budget.find(budget.id)
spending_without_refund = budget.actual_spending
assert_equal 150, spending_without_refund - spending_with_refund
end
test "previous_budget_param returns param when date is valid" do
budget = Budget.create!(
family: @family,