mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
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
260 lines
7.6 KiB
Ruby
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
|