Files
sure/app/models/assistant/function/get_budget.rb
T
e5750a6c09 feat(budgets): add per-user personal budgets with strict isolation (#2891)
* feat(budgets): add per-user personal budgets with strict isolation

Families can now opt into personal budgets (toggleable via family
settings): each family member gets their own budget for a given
period instead of sharing a single family-wide budget.

- Add families.personal_budgets flag and budgets.user_id, with
  partial unique indexes so shared budgets (user_id IS NULL) and
  personal budgets (user_id IS NOT NULL) can't collide.
- Budget.find_or_bootstrap scopes lookup/creation by user when the
  family has personal_budgets enabled.
- Scope most_recent_initialized_budget (used to seed a new budget
  from the prior period) by user_id so one user's copy-forward never
  bleeds into another user's budget.
- budgets.user_id cascades on user deletion so personal budgets don't
  outlive their owner.

* feat(budgets): enforce user-specific budget ownership and cascade deletion

* feat(budgets): display user name for personal budgets in budget card on the plan section

* feat(budgets): enhance personal budgets display for admins with preview feature indication

* feat(budgets): enforce user-specific budget and category visibility for personal budgets

* feat(budgets): create budget section titles and add translations notice in preferences

* feat(budgets): let household and personal budgets coexist with sharing

Previously enabling personal_budgets made the shared household budget
unreachable. Budget.find_or_bootstrap now takes an explicit household:
flag so both can be resolved independently for the same period, with a
new household_budget_enabled family setting to opt out of the household
side and keep personal budgets only.

Adds a BudgetShare model (read_only/read_write) so a member can grant
another family member access to their personal budget, enforced via
Budget#viewable_by?/editable_by? across BudgetsController,
BudgetCategoriesController, PlansController, and the read-only API.
Preferences gains a Budget sharing card (gated on preview access like
the rest of the personal budgets UI) and an owner switcher pill (
Household / mine / shared-with-me) appears on the budget page and the
Plan hub card.

Also fixes personal budgets showing the same "actual spending" as the
household budget: actual spending/income now scope to the budget
owner's own accounts instead of the viewer's full accessible set,
via a new accounts: override on IncomeStatement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(budgets): enhance budget switcher with icons and improved styling

* feat(budgets): remove user name display from budget card and header

* feat(budgets): remove unique index on taggable_type and taggable_id in taggings

* feat(budgets): enhance budget sharing functionality and improve UI elements

* Collapse personal budget migrations

---------

Signed-off-by: JulienGourmet <69808509+jubbakka@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-18 08:36:46 +02:00

182 lines
5.8 KiB
Ruby

class Assistant::Function::GetBudget < Assistant::Function
include ActiveSupport::NumberHelper
include Assistant::Function::MonthResolvable
MAX_PRIOR_MONTHS = 11
class << self
def name
"get_budget"
end
def description
<<~INSTRUCTIONS
Use this to see how the user is tracking against their monthly budget total
budgeted vs spent and a parent/subcategory breakdown matching the budget UI.
This is great for answering questions like:
- How am I tracking against my budget this month?
- Which categories am I over budget on?
- How does this month's spending compare to the last few months?
Parameters:
- `month` (optional): "YYYY-MM" or "MMM-YYYY". Defaults to the current month.
- `prior_months` (optional): integer 0..#{MAX_PRIOR_MONTHS}. Number of months
preceding the target month to include for trend comparison. Default 0.
Example (current month only):
```
get_budget({})
```
Example (current month plus last 2 months):
```
get_budget({ month: "#{Date.current.strftime('%Y-%m')}", prior_months: 2 })
```
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
properties: {
month: {
type: "string",
description: "Target month in YYYY-MM or MMM-YYYY format. Defaults to the current month."
},
prior_months: {
type: "integer",
description: "Number of months before the target month to also return for trend comparison.",
minimum: 0,
maximum: MAX_PRIOR_MONTHS
}
}
)
end
def call(params = {})
target_start = resolve_month_start(params["month"])
prior = [ params["prior_months"].to_i, 0 ].max
prior = [ prior, MAX_PRIOR_MONTHS ].min
month_starts = (0..prior).map { |offset| shift_months(target_start, -offset) }.reverse
requested = month_starts.count { |start_date| Budget.budget_date_valid?(start_date, family: family) }
months = month_starts.filter_map do |start_date|
next unless Budget.budget_date_valid?(start_date, family: family)
build_month_payload(start_date, bootstrap: start_date == target_start)
end
result = {
currency: family.currency,
months: months
}
unavailable = requested - months.length
result[:months_unavailable] = unavailable if unavailable > 0
result
end
private
def build_month_payload(start_date, bootstrap:)
budget = if bootstrap
Budget.find_or_bootstrap(family, start_date: start_date, user: user)
else
budget_start, budget_end = Budget.period_for(start_date, family: family)
family.budgets.find_by(
start_date: budget_start,
end_date: budget_end,
user: family.personal_budgets? ? user : nil
)
end
return nil unless budget
groups = BudgetCategory::Group.for(budget.budget_categories)
{
month: budget.to_param,
period: {
start_date: budget.start_date,
end_date: budget.end_date
},
is_current: budget.current?,
initialized: budget.initialized?,
totals: {
budgeted_spending: format_money(budget.budgeted_spending),
allocated_spending: format_money(budget.allocated_spending),
available_to_allocate: format_money(budget.available_to_allocate),
actual_spending: format_money(budget.actual_spending),
available_to_spend: format_money(budget.available_to_spend),
percent_of_budget_spent: format_percent(budget.initialized? ? budget.percent_of_budget_spent : 0),
overage_percent: format_percent(budget.overage_percent)
},
income: {
expected_income: format_money(budget.expected_income),
actual_income: format_money(budget.actual_income),
remaining_expected_income: format_money((budget.expected_income || 0) - budget.actual_income)
},
categories: groups.map { |group| serialize_group(group, include_daily_suggestion: budget.current?) }
}
end
def serialize_group(group, include_daily_suggestion:)
parent = group.budget_category
serialize_category(parent, include_daily_suggestion: include_daily_suggestion).merge(
color: parent.category.color,
subcategories: group.budget_subcategories.map do |sub|
serialize_category(sub, include_daily_suggestion: include_daily_suggestion).merge(
inherits_parent_budget: sub.inherits_parent_budget?
)
end
)
end
def serialize_category(bc, include_daily_suggestion:)
payload = {
name: bc.name,
budgeted: format_money(bc.display_budgeted_spending),
actual: format_money(bc.actual_spending),
available: format_money(bc.available_to_spend),
percent_spent: format_percent(bc.percent_of_budget_spent || 0),
status: category_status(bc)
}
if include_daily_suggestion
suggestion = bc.suggested_daily_spending
payload[:suggested_daily_spending] = suggestion[:amount].format if suggestion
end
payload
end
def category_status(bc)
return "over_budget" if bc.over_budget_with_budget?
return "unbudgeted" if bc.unbudgeted_with_spending?
return "near_limit" if bc.budgeted? && bc.near_limit?
return "on_track" if bc.on_track?
"no_activity"
end
def shift_months(date, n)
shifted = date >> n
if family.uses_custom_month_start?
family.custom_month_start_for(shifted)
else
shifted.beginning_of_month
end
end
def format_money(value)
Money.new(value || 0, family.currency).format
end
def format_percent(value)
number_to_percentage(value || 0, precision: 1)
end
end