mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 13:21:17 +00:00
* 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>
182 lines
5.8 KiB
Ruby
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
|