2025-01-16 14:36:37 -05:00
|
|
|
class Budget < ApplicationRecord
|
|
|
|
include Monetizable
|
|
|
|
|
|
|
|
belongs_to :family
|
|
|
|
|
|
|
|
has_many :budget_categories, 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,
|
2025-01-16 19:05:34 -05:00
|
|
|
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income
|
2025-01-16 14:36:37 -05:00
|
|
|
|
|
|
|
class << self
|
|
|
|
def for_date(date)
|
|
|
|
find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
|
|
|
|
end
|
|
|
|
|
|
|
|
def find_or_bootstrap(family, date: Date.current)
|
|
|
|
Budget.transaction do
|
2025-01-16 19:05:34 -05:00
|
|
|
budget = Budget.find_or_create_by!(
|
2025-01-16 14:36:37 -05:00
|
|
|
family: family,
|
|
|
|
start_date: date.beginning_of_month,
|
2025-01-16 19:05:34 -05:00
|
|
|
end_date: date.end_of_month
|
|
|
|
) do |b|
|
|
|
|
b.currency = family.currency
|
|
|
|
end
|
2025-01-16 14:36:37 -05:00
|
|
|
|
|
|
|
budget.sync_budget_categories
|
|
|
|
|
|
|
|
budget
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def sync_budget_categories
|
|
|
|
family.categories.expenses.each do |category|
|
|
|
|
budget_categories.find_or_create_by(
|
|
|
|
category: category,
|
|
|
|
) do |bc|
|
|
|
|
bc.budgeted_spending = 0
|
|
|
|
bc.currency = family.currency
|
|
|
|
end
|
|
|
|
end
|
|
|
|
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 entries
|
|
|
|
family.entries.incomes_and_expenses.where(date: start_date..end_date)
|
|
|
|
end
|
|
|
|
|
|
|
|
def name
|
|
|
|
start_date.strftime("%B %Y")
|
|
|
|
end
|
|
|
|
|
|
|
|
def initialized?
|
|
|
|
budgeted_spending.present?
|
|
|
|
end
|
|
|
|
|
|
|
|
def income_categories_with_totals
|
|
|
|
family.income_categories_with_totals(date: start_date)
|
|
|
|
end
|
|
|
|
|
|
|
|
def expense_categories_with_totals
|
|
|
|
family.expense_categories_with_totals(date: start_date)
|
|
|
|
end
|
|
|
|
|
|
|
|
def current?
|
|
|
|
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
|
|
|
|
end
|
|
|
|
|
|
|
|
def previous_budget
|
|
|
|
prev_month_end_date = end_date - 1.month
|
|
|
|
return nil if prev_month_end_date < family.oldest_entry_date
|
|
|
|
family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
|
|
|
|
end
|
|
|
|
|
|
|
|
def next_budget
|
|
|
|
return nil if current?
|
|
|
|
next_start_date = start_date + 1.month
|
|
|
|
family.budgets.find_or_bootstrap(family, date: next_start_date)
|
|
|
|
end
|
|
|
|
|
|
|
|
def to_donut_segments_json
|
|
|
|
unused_segment_id = "unused"
|
|
|
|
|
|
|
|
# Continuous gray segment for empty budgets
|
|
|
|
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
|
|
|
|
|
2025-01-27 16:34:13 +02:00
|
|
|
segments = budget_categories.includes(:category).map do |bc|
|
2025-01-16 14:36:37 -05:00
|
|
|
{ color: bc.category.color, amount: bc.actual_spending, id: bc.id }
|
|
|
|
end
|
|
|
|
|
|
|
|
if available_to_spend.positive?
|
|
|
|
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
|
|
|
end
|
|
|
|
|
|
|
|
segments
|
|
|
|
end
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
# Actuals: How much user has spent on each budget category
|
|
|
|
# =============================================================================
|
|
|
|
def estimated_spending
|
|
|
|
family.budgeting_stats.avg_monthly_expenses&.abs
|
|
|
|
end
|
|
|
|
|
|
|
|
def actual_spending
|
2025-01-27 09:29:50 -05:00
|
|
|
expense_categories_with_totals.total_money.amount
|
2025-01-16 14:36:37 -05:00
|
|
|
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 categories combined
|
|
|
|
# =============================================================================
|
|
|
|
def allocated_spending
|
|
|
|
budget_categories.sum(:budgeted_spending)
|
|
|
|
end
|
|
|
|
|
|
|
|
def allocated_percent
|
2025-01-16 16:24:14 -05:00
|
|
|
return 0 unless budgeted_spending && budgeted_spending > 0
|
2025-01-16 14:36:37 -05:00
|
|
|
|
|
|
|
(allocated_spending / budgeted_spending.to_f) * 100
|
|
|
|
end
|
|
|
|
|
|
|
|
def available_to_allocate
|
|
|
|
(budgeted_spending || 0) - allocated_spending
|
|
|
|
end
|
|
|
|
|
|
|
|
def allocations_valid?
|
2025-02-05 19:20:19 +01:00
|
|
|
initialized? && available_to_allocate >= 0 && allocated_spending > 0
|
2025-01-16 14:36:37 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
# Income: How much user earned relative to what they expected to earn
|
|
|
|
# =============================================================================
|
|
|
|
def estimated_income
|
|
|
|
family.budgeting_stats.avg_monthly_income&.abs
|
|
|
|
end
|
|
|
|
|
|
|
|
def actual_income
|
|
|
|
family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
|
|
|
|
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
|
|
|
|
end
|