mirror of
https://github.com/we-promise/sure.git
synced 2026-04-08 14:54:49 +00:00
* Add customizable budget month start day (#253) Allow users to set a custom month-to-date start date (1st-28th) for budgeting and MTD calculations. Useful for users who want budget periods aligned with their pay schedule (e.g., 25th to 24th). Changes: - Add month_start_day column to families table (default: 1) - Add database check constraint for valid range (1-28) - Add Family#uses_custom_month_start?, custom_month_start_for, custom_month_end_for, current_custom_month_period helper methods - Add Period.current_month_for(family), last_month_for(family) methods - Update Budget model for custom month boundaries in find_or_bootstrap, param_to_date, budget_date_valid?, current?, and name methods - Add month_start_day setting to Settings > Preferences UI - Add warning message when custom month start day is configured - Add comprehensive tests with travel_to for date robustness Fixes #253 * Add /api/v1/user endpoint for Flutter mobile app and PWA Expose user preferences including month_start_day via API endpoint following existing pattern for default_period. This allows Flutter mobile app and PWA to read/update user preferences through a consistent API contract. Endpoints: - GET /api/v1/user - Read user preferences including family settings - PATCH /api/v1/user - Update user preferences Response includes: id, email, first_name, last_name, default_period, locale, and family settings (currency, timezone, date_format, country, month_start_day). * Update Periodable to use family-aware MTD periods When users select 'current_month' or 'last_month' period filters on dashboard/reports, now respects the family's custom month_start_day setting instead of using static calendar month boundaries. This ensures MTD filter on dashboard is consistent with how budgets calculate their periods when custom month start day is configured. * Fix param_to_date to correctly map budget params to custom periods When a family uses a custom start day, the previous implementation called custom_month_start_for on the 1st of the month, which incorrectly shifted dates before the start day to the previous month. Now we directly construct the date using family.month_start_day, so 'jan-2026' with month_start_day=25 correctly returns Jan 25, 2026 instead of Dec 25, 2025. * Fix param_to_date and use Current pattern in API controller - Fix param_to_date to directly construct date with family.month_start_day instead of using custom_month_start_for which incorrectly shifted dates - Replace current_user with Current.user/Current.family in API controller to follow project convention used in other API v1 controllers * Add i18n for budget name method Use I18n.t for localizable budget period names to follow project conventions for user-facing strings. * Remove unused budget_end variable in budget_date_valid? * Use Date.current for timezone consistency in Budget#current? * Address PR review feedback - Remove API users endpoint (mobile won't use yet) - Remove user route from config/routes.rb - Remove ai_summary/document_type schema bleed from pdf-import-ai branch * Pass family to param_to_date for custom month logic * Run migration to add month_start_day column to schema * Schema regressions --------- Co-authored-by: mkdev11 <jaysmth689+github@users.noreply.github.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
298 lines
8.9 KiB
Ruby
298 lines
8.9 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, family: nil)
|
|
base_date = Date.strptime(param, PARAM_DATE_FORMAT)
|
|
if family&.uses_custom_month_start?
|
|
Date.new(base_date.year, base_date.month, family.month_start_day)
|
|
else
|
|
base_date.beginning_of_month
|
|
end
|
|
end
|
|
|
|
def budget_date_valid?(date, family:)
|
|
if family.uses_custom_month_start?
|
|
budget_start = family.custom_month_start_for(date)
|
|
budget_start >= oldest_valid_budget_date(family) && budget_start <= family.custom_month_end_for(Date.current)
|
|
else
|
|
beginning_of_month = date.beginning_of_month
|
|
beginning_of_month >= oldest_valid_budget_date(family) && beginning_of_month <= Date.current.end_of_month
|
|
end
|
|
end
|
|
|
|
def find_or_bootstrap(family, start_date:)
|
|
return nil unless budget_date_valid?(start_date, family: family)
|
|
|
|
Budget.transaction do
|
|
if family.uses_custom_month_start?
|
|
budget_start = family.custom_month_start_for(start_date)
|
|
budget_end = family.custom_month_end_for(start_date)
|
|
else
|
|
budget_start = start_date.beginning_of_month
|
|
budget_end = start_date.end_of_month
|
|
end
|
|
|
|
budget = Budget.find_or_create_by!(
|
|
family: family,
|
|
start_date: budget_start,
|
|
end_date: budget_end
|
|
) do |b|
|
|
b.currency = family.currency
|
|
end
|
|
|
|
budget.sync_budget_categories
|
|
|
|
budget
|
|
end
|
|
end
|
|
|
|
private
|
|
def oldest_valid_budget_date(family)
|
|
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
|
|
if family.uses_custom_month_start?
|
|
I18n.t(
|
|
"budgets.name.custom_range",
|
|
start: start_date.strftime("%b %d"),
|
|
end_date: end_date.strftime("%b %d, %Y")
|
|
)
|
|
else
|
|
I18n.t("budgets.name.month_year", month: start_date.strftime("%B %Y"))
|
|
end
|
|
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?
|
|
if family.uses_custom_month_start?
|
|
current_period = family.current_custom_month_period
|
|
start_date == current_period.start_date && end_date == current_period.end_date
|
|
else
|
|
start_date == Date.current.beginning_of_month && end_date == Date.current.end_of_month
|
|
end
|
|
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)
|
|
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)
|
|
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) || ct.category.uncategorized? }
|
|
.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
|
|
|
|
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
|