Files
sure/app/models/budget.rb
eureka928 787e04f4fe 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
2026-01-29 13:49:49 +01:00

260 lines
7.6 KiB
Ruby

class Budget < ApplicationRecord
include Monetizable
PARAM_DATE_FORMAT = "%b-%Y"
belongs_to :family
has_many :budget_categories, -> { includes(:category) }, dependent: :destroy
validates :start_date, :end_date, presence: true
validates :start_date, :end_date, uniqueness: { scope: :family_id }
monetize :budgeted_spending, :expected_income, :allocated_spending,
:actual_spending, :available_to_spend, :available_to_allocate,
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income
class << self
def date_to_param(date)
date.strftime(PARAM_DATE_FORMAT).downcase
end
def param_to_date(param)
Date.strptime(param, PARAM_DATE_FORMAT).beginning_of_month
end
def budget_date_valid?(date, family:)
beginning_of_month = date.beginning_of_month
beginning_of_month >= oldest_valid_budget_date(family) && beginning_of_month <= Date.current.end_of_month
end
def find_or_bootstrap(family, start_date:)
return nil unless budget_date_valid?(start_date, family: family)
Budget.transaction do
budget = Budget.find_or_create_by!(
family: family,
start_date: start_date.beginning_of_month,
end_date: start_date.end_of_month
) do |b|
b.currency = family.currency
end
budget.sync_budget_categories
budget
end
end
private
def oldest_valid_budget_date(family)
# Allow going back to either the earliest entry date OR 2 years ago, whichever is earlier
two_years_ago = 2.years.ago.beginning_of_month
oldest_entry_date = family.oldest_entry_date.beginning_of_month
[ two_years_ago, oldest_entry_date ].min
end
end
def period
Period.custom(start_date: start_date, end_date: end_date)
end
def to_param
self.class.date_to_param(start_date)
end
def sync_budget_categories
current_category_ids = family.categories.expenses.pluck(:id).to_set
existing_budget_category_ids = budget_categories.pluck(:category_id).to_set
categories_to_add = current_category_ids - existing_budget_category_ids
categories_to_remove = existing_budget_category_ids - current_category_ids
# Create missing categories
categories_to_add.each do |category_id|
budget_categories.create!(
category_id: category_id,
budgeted_spending: 0,
currency: family.currency
)
end
# Remove old categories
budget_categories.where(category_id: categories_to_remove).destroy_all if categories_to_remove.any?
end
def uncategorized_budget_category
budget_categories.uncategorized.tap do |bc|
bc.budgeted_spending = [ available_to_allocate, 0 ].max
bc.currency = family.currency
end
end
def transactions
family.transactions.visible.in_period(period)
end
def name
start_date.strftime("%B %Y")
end
def initialized?
budgeted_spending.present?
end
def income_category_totals
income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse
end
def expense_category_totals
expense_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse
end
def current?
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
end
def previous_budget_param
previous_date = start_date - 1.month
return nil unless self.class.budget_date_valid?(previous_date, family: family)
self.class.date_to_param(previous_date)
end
def next_budget_param
return nil if current?
next_date = start_date + 1.month
return nil unless self.class.budget_date_valid?(next_date, family: family)
self.class.date_to_param(next_date)
end
def to_donut_segments_json
unused_segment_id = "unused"
# Continuous gray segment for empty budgets
return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: unused_segment_id } ] unless allocations_valid?
segments = budget_categories.map do |bc|
{ color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id }
end
if available_to_spend.positive?
segments.push({ color: "var(--budget-unallocated-fill)", amount: available_to_spend, id: unused_segment_id })
end
segments
end
# =============================================================================
# Actuals: How much user has spent on each budget category
# =============================================================================
def estimated_spending
income_statement.median_expense(interval: "month")
end
def actual_spending
[ expense_totals.total - refunds_in_expense_categories, 0 ].max
end
def budget_category_actual_spending(budget_category)
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)
income_statement.median_expense(category: category)
end
def category_avg_monthly_expense(category)
income_statement.avg_expense(category: category)
end
def available_to_spend
(budgeted_spending || 0) - actual_spending
end
def percent_of_budget_spent
return 0 unless budgeted_spending > 0
(actual_spending / budgeted_spending.to_f) * 100
end
def overage_percent
return 0 unless available_to_spend.negative?
available_to_spend.abs / actual_spending.to_f * 100
end
# =============================================================================
# Budget allocations: How much user has budgeted for all parent categories combined
# =============================================================================
def allocated_spending
budget_categories.reject { |bc| bc.subcategory? }.sum(&:budgeted_spending)
end
def allocated_percent
return 0 unless budgeted_spending && budgeted_spending > 0
(allocated_spending / budgeted_spending.to_f) * 100
end
def available_to_allocate
(budgeted_spending || 0) - allocated_spending
end
def allocations_valid?
initialized? && available_to_allocate >= 0 && allocated_spending > 0
end
# =============================================================================
# Income: How much user earned relative to what they expected to earn
# =============================================================================
def estimated_income
family.income_statement.median_income(interval: "month")
end
def actual_income
family.income_statement.income_totals(period: self.period).total
end
def actual_income_percent
return 0 unless expected_income > 0
(actual_income / expected_income.to_f) * 100
end
def remaining_expected_income
expected_income - actual_income
end
def surplus_percent
return 0 unless remaining_expected_income.negative?
remaining_expected_income.abs / expected_income.to_f * 100
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
def expense_totals
@expense_totals ||= income_statement.expense_totals(period: period)
end
def income_totals
@income_totals ||= family.income_statement.income_totals(period: period)
end
end