Files
sure/app/models/assistant/function/month_resolvable.rb
T
Brandon WolfandClaude Fable 5 00b7252fbf feat(mcp): Add MCP budget update tool (#2908)
* 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>
2026-08-11 23:49:43 +02:00

30 lines
1.1 KiB
Ruby

# Shared month handling for budget-facing assistant tools so month slugs
# round-trip between get_budget and update_budget, including families with a
# custom month start day.
module Assistant::Function::MonthResolvable
private
def resolve_month_start(raw)
base = parse_month(raw)
return (base || Date.current).beginning_of_month unless family.uses_custom_month_start?
# Match Budget.param_to_date for explicit slugs so the input round-trips with the response.
base ? Date.new(base.year, base.month, family.month_start_day) : family.custom_month_start_for(Date.current)
end
def parse_month(raw)
return nil if raw.blank?
# Date.strptime ignores trailing characters, so guard with strict anchors first.
fmt = case raw
when /\A\d{4}-\d{2}\z/ then "%Y-%m"
when /\A[A-Za-z]{3}-\d{4}\z/ then "%b-%Y"
end
raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY." if fmt.nil?
Date.strptime(raw, fmt)
rescue ArgumentError
raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY."
end
end