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

@@ -155,11 +155,14 @@ 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
cat_id = budget_category.category_id
expense = expense_totals_by_category[cat_id]&.total || 0
refund = income_totals_by_category[cat_id]&.total || 0
[ expense - refund, 0 ].max
end
def category_median_monthly_expense(category)
@@ -235,6 +238,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) || ct.category.uncategorized? }
.sum(&:total)
end
def income_statement
@income_statement ||= family.income_statement
end
@@ -246,4 +257,12 @@ class Budget < ApplicationRecord
def income_totals
@income_totals ||= family.income_statement.income_totals(period: period)
end
def expense_totals_by_category
@expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id }
end
def income_totals_by_category
@income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id }
end
end