mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 13:21:17 +00:00
* feat(mcp): Add MCP budget update tool Adds an update_budget assistant/MCP function so AI assistants can write monthly budgets: total budgeted spending, expected income, and per-category allocations in one transactional call. - Month resolution and slug format mirror get_budget (YYYY-MM or MMM-YYYY, custom month start respected); targeting a valid month with no budget row bootstraps it via Budget.find_or_bootstrap, same as the budgets UI. - Category allocations accept an exact (case-insensitive) name or id and go through BudgetCategory#update_budgeted_spending!, so subcategory writes keep the parent total in sync. - All writes in one call share a transaction: an invalid category rolls back a totals change from the same call. - Family-scoped like the budgets UI; amounts validated non-negative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(mcp): harden update_budget per review feedback - Extract shared month resolution into Assistant::Function::MonthResolvable so get_budget and update_budget can't drift on custom month starts - Run budget bootstrap inside the update transaction so a failed entry no longer leaves a newly created budget behind - Apply explicit parent amounts after subcategory syncs so results don't depend on the caller's array order - Reject non-finite amounts (NaN/Infinity) - Explain the synthetic Uncategorized bucket instead of a generic category-not-found error Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
182 lines
6.7 KiB
Ruby
182 lines
6.7 KiB
Ruby
class Assistant::Function::UpdateBudget < Assistant::Function
|
|
include Assistant::Function::MonthResolvable
|
|
|
|
class << self
|
|
def name
|
|
"update_budget"
|
|
end
|
|
|
|
def description
|
|
<<~INSTRUCTIONS
|
|
Updates the user's monthly budget: total budgeted spending, expected income,
|
|
and/or per-category budgeted amounts.
|
|
|
|
Call get_budget first to see current amounts and exact category names.
|
|
Amounts are plain non-negative numbers in the family's currency. Only the
|
|
fields and categories you pass are changed. Setting a subcategory's amount
|
|
keeps its parent's total in sync automatically. The "Uncategorized" bucket
|
|
cannot be set directly — it is the unallocated remainder of total budgeted
|
|
spending.
|
|
|
|
Parameters:
|
|
- `month` (optional): "YYYY-MM" or "MMM-YYYY". Defaults to the current month.
|
|
- `budgeted_spending` (optional): total planned spending for the month.
|
|
- `expected_income` (optional): expected income for the month.
|
|
- `categories` (optional): array of { category: <name or id>, amount: <number> }.
|
|
|
|
At least one of budgeted_spending, expected_income, or categories is required.
|
|
|
|
Example (set the total and two category allocations for August 2026):
|
|
|
|
```
|
|
update_budget({
|
|
month: "2026-08",
|
|
budgeted_spending: 6500,
|
|
categories: [
|
|
{ category: "Groceries", amount: 900 },
|
|
{ category: "Dining Out", amount: 250 }
|
|
]
|
|
})
|
|
```
|
|
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."
|
|
},
|
|
budgeted_spending: {
|
|
type: "number",
|
|
minimum: 0,
|
|
description: "Total planned spending for the month, in the family currency."
|
|
},
|
|
expected_income: {
|
|
type: "number",
|
|
minimum: 0,
|
|
description: "Expected income for the month, in the family currency."
|
|
},
|
|
categories: {
|
|
type: "array",
|
|
description: "Per-category budget allocations to set.",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
category: {
|
|
type: "string",
|
|
description: "Category name (exact match, case-insensitive) or category id (use get_categories)."
|
|
},
|
|
amount: {
|
|
type: "number",
|
|
minimum: 0,
|
|
description: "New budgeted amount for this category."
|
|
}
|
|
},
|
|
required: [ "category", "amount" ],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
}
|
|
)
|
|
end
|
|
|
|
def call(params = {})
|
|
category_changes = Array(params["categories"])
|
|
unless params.key?("budgeted_spending") || params.key?("expected_income") || category_changes.any?
|
|
return error("no_changes", "Provide at least one of budgeted_spending, expected_income, or categories.")
|
|
end
|
|
|
|
start_date = resolve_month_start(params["month"])
|
|
unless Budget.budget_date_valid?(start_date, family: family)
|
|
return error("invalid_month", "No budget exists (or can be created) for that month — it is outside the valid budget range.")
|
|
end
|
|
|
|
attrs = {}
|
|
attrs[:budgeted_spending] = parse_amount!(params["budgeted_spending"], "budgeted_spending") if params.key?("budgeted_spending")
|
|
attrs[:expected_income] = parse_amount!(params["expected_income"], "expected_income") if params.key?("expected_income")
|
|
|
|
budget = nil
|
|
updated = []
|
|
# Bootstrap and all writes share one transaction so a bad entry can't
|
|
# leave a newly created (or half-updated) budget behind.
|
|
Budget.transaction do
|
|
budget = Budget.find_or_bootstrap(family, start_date: start_date, user: user)
|
|
|
|
budget.update!(attrs) if attrs.any?
|
|
|
|
changes = category_changes.map do |change|
|
|
budget_category = find_budget_category!(budget, change.is_a?(Hash) ? change["category"] : nil)
|
|
[ budget_category, parse_amount!(change["amount"], "amount for '#{budget_category.name}'") ]
|
|
end
|
|
|
|
# Subcategory updates sync their parent's total, so explicit parent
|
|
# amounts apply last to keep results independent of the array order.
|
|
subcategories, parents = changes.partition { |budget_category, _amount| budget_category.subcategory? }
|
|
(subcategories + parents).each do |budget_category, amount|
|
|
budget_category.update_budgeted_spending!(amount)
|
|
updated << { category: budget_category.name, budgeted_spending: format_money(budget_category.reload.budgeted_spending) }
|
|
end
|
|
end
|
|
|
|
budget.reload
|
|
{
|
|
success: true,
|
|
month: budget.to_param,
|
|
totals: {
|
|
budgeted_spending: format_money(budget.budgeted_spending),
|
|
expected_income: format_money(budget.expected_income),
|
|
allocated_spending: format_money(budget.allocated_spending),
|
|
available_to_allocate: format_money(budget.available_to_allocate)
|
|
},
|
|
updated_categories: updated,
|
|
message: "Budget for #{budget.start_date.strftime('%B %Y')} updated."
|
|
}
|
|
rescue Assistant::Error => e
|
|
error("invalid_params", e.message)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
error("validation_failed", e.record.errors.full_messages.join("; "))
|
|
end
|
|
|
|
private
|
|
def parse_amount!(raw, label)
|
|
value = Float(raw)
|
|
raise Assistant::Error, "#{label} must be a non-negative number." if !value.finite? || value.negative?
|
|
value
|
|
rescue ArgumentError, TypeError
|
|
raise Assistant::Error, "#{label} must be a non-negative number."
|
|
end
|
|
|
|
def find_budget_category!(budget, ref)
|
|
ref = ref.to_s.strip
|
|
raise Assistant::Error, "Each categories entry needs a category name or id." if ref.blank?
|
|
|
|
category = valid_uuid?(ref) ? family.categories.find_by(id: ref) : nil
|
|
category ||= family.categories.where("LOWER(name) = ?", ref.downcase).first
|
|
|
|
if category.nil?
|
|
if Category.all_uncategorized_names.any? { |name| name.casecmp?(ref) }
|
|
raise Assistant::Error, "'#{ref}' is the unallocated remainder of budgeted_spending and cannot be set directly. Adjust budgeted_spending or category amounts instead."
|
|
end
|
|
raise Assistant::Error, "Category '#{ref}' not found. Use get_categories to list categories."
|
|
end
|
|
|
|
budget.budget_categories.find_by(category_id: category.id) ||
|
|
raise(Assistant::Error, "No budget row exists for category '#{category.name}' in #{budget.to_param}.")
|
|
end
|
|
|
|
def format_money(value)
|
|
Money.new(value || 0, family.currency).format
|
|
end
|
|
|
|
def error(key, message)
|
|
{ success: false, error: key, message: message }
|
|
end
|
|
end
|