Skip to content

feat(assistant): add get_budget function for budget tracking#1966

Open
wolstad wants to merge 5 commits into
we-promise:mainfrom
wolstad:budget-mcp-tracking-tools
Open

feat(assistant): add get_budget function for budget tracking#1966
wolstad wants to merge 5 commits into
we-promise:mainfrom
wolstad:budget-mcp-tracking-tools

Conversation

@wolstad
Copy link
Copy Markdown

@wolstad wolstad commented May 24, 2026

Summary

Adds an Assistant::Function::GetBudget so the AI assistant can answer questions like "how am I tracking against my budget this month?" or "which categories am I over budget on?". The response shape mirrors the budget UI: monthly totals, income, and a parent/subcategory breakdown with per-category status (over_budget, unbudgeted, near_limit, on_track, no_activity) plus suggested daily spend for the current month. Accepts an optional month (YYYY-MM or MMM-YYYY) and prior_months window (0..11) for trend comparison.

Honors custom month_start_day by matching Budget.param_to_date for explicit slugs, so the month input round-trips with the response's month field.

Test plan

  • bin/rails test test/models/assistant/function/get_budget_test.rb
  • Smoke test the AI assistant against a real budget — verify totals, category status, and prior_months trend
  • Verify slug round-trip for a family with month_start_day != 1

Summary by CodeRabbit

  • New Features

    • New assistant budget-report function: month-by-month reports (up to 11 prior months) with totals, percent/overage metrics, hierarchical category breakdowns, status indicators, and per-category daily suggestions for the current month; accepts flexible month formats and honors family-specific month starts.
  • Bug Fixes

    • Daily-spending suggestions now use budget end date for custom month periods.
  • Tests

    • Added end-to-end tests for parsing, month ranges, response shape, status logic, and pacing totals.

Review Change Stack

Exposes the existing Budget / BudgetCategory pacing data to the AI
assistant as a `get_budget` function. Supports a target month and an
optional `prior_months` window for trend comparison, with the response
shape matching the budget UI (totals, income, per-category status,
suggested daily spend on the current month).

Honors custom month_start_day by matching `Budget.param_to_date`
semantics for explicit slug input, so `month` round-trips with the
response's `month` field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@superagent-security superagent-security Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels May 24, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 24, 2026

📝 Walkthrough

Walkthrough

Adds Assistant::Function::GetBudget and registers it; the function parses month inputs (including custom family month starts), builds month-by-month budget payloads with totals and hierarchical categories, computes category statuses and conditional daily suggestions, centralizes budget period logic, and adds end-to-end tests.

Changes

Budget Query Function

Layer / File(s) Summary
Function registration and execution entry point
app/models/assistant.rb, app/models/assistant/function/get_budget.rb
GetBudget added to Assistant.function_classes; defines public interface (name, description, strict_mode, params_schema) and main call orchestration building the top-level response and month list.
Month parsing and formatting helpers
app/models/assistant/function/get_budget.rb (helpers)
Parses YYYY-MM and MMM-YYYY, resolves month start using family.month_start_day, shifts months across custom boundaries, and provides format_money/format_percent helpers; maps category flags to a single status string.
Budget data retrieval and category serialization
app/models/assistant/function/get_budget.rb, app/models/budget_category.rb
Constructs per-month payloads (fetch/bootstrap budget), computes totals (budgeted/allocated/available/actual, income, percent/overage), serializes parent+subcategory hierarchies, and uses budget.current?/budget.end_date for suggested_daily_spending logic.
Budget period helper and callers
app/models/budget.rb
Adds Budget.period_for(date, family:) and updates budget_date_valid? and find_or_bootstrap to use the shared period computation for custom month boundaries.
Test suite
test/models/assistant/function/get_budget_test.rb, test/models/budget_category_test.rb
Tests function metadata and params schema, month defaulting/parsing (including custom month-start), invalid input errors, payload shape (category nesting, parent color), status derivation via stubbing, omission of daily suggestions for non-current months, presence of pacing keys in totals, and a budget_category test for custom month periods.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Assistant_GetBudget
  participant Budget
  participant BudgetCategory
  Client->>Assistant_GetBudget: call(params month, prior_months)
  Assistant_GetBudget->>Budget: period_for(month) / find_or_bootstrap(period)
  Budget-->>Assistant_GetBudget: budget + categories
  Assistant_GetBudget->>BudgetCategory: compute suggested_daily_spending/status
  Assistant_GetBudget-->>Client: structured month-by-month payload
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • we-promise/sure#925: Related changes to Budget month-range/date validation and start-bound logic.
  • we-promise/sure#810: Related work on custom budget month start behavior and Budget#current? semantics.

Suggested reviewers

  • jjmata
  • sokie

Poem

🐰 I hopped through months with coins in tow,
I parsed the starts where custom breezes blow,
I nested categories, counted every cent,
Suggested little sips for days not spent,
A rabbit's ledger: tidy, warm, and slow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a new get_budget function to the assistant for budget tracking, which is the primary purpose of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/models/assistant/function/get_budget_test.rb (1)

94-104: ⚡ Quick win

Consider using fixture references instead of hard-coded category names.

Line 96 finds a budget category by comparing bc.category.name == "Food & Drink". This approach is fragile if category names change due to localization or data updates. A more robust approach would use a fixture reference or a stable identifier.

Based on learnings, string comparisons for category identification should be avoided in favor of predicates or identity checks.

♻️ Suggested approach using fixture reference

If a fixture is defined for the Food & Drink category, reference it directly:

-    food_bc = budget.budget_categories.find { |bc| bc.category.name == "Food & Drink" }
+    food_category = categories(:food_and_drink)
+    food_bc = budget.budget_categories.find { |bc| bc.category == food_category }

Alternatively, if a predicate exists:

-    food_bc = budget.budget_categories.find { |bc| bc.category.name == "Food & Drink" }
+    food_bc = budget.budget_categories.find { |bc| bc.category.food_and_drink? }

Based on learnings: Do not compare hard-coded strings to identify categories in Ruby code. Prefer model-level predicates or identity checks with canonical categories for maintainability across translations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/assistant/function/get_budget_test.rb` around lines 94 - 104,
Replace the fragile string match that finds the "Food & Drink" budget category
with a stable reference or predicate: instead of budget.budget_categories.find {
|bc| bc.category.name == "Food & Drink" } use a fixture/model identity or
predicate (e.g., use the fixture object for the food category or a model
predicate like bc.category.food? or compare bc.category.id to the fixture's id)
so the test uses Budget, budget.budget_categories, and the same category
fixture/identifier used elsewhere; keep the rest of the test
(BudgetCategory.any_instance.stubs(:actual_spending) and `@function.call`({}))
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/models/assistant/function/get_budget_test.rb`:
- Around line 94-104: Replace the fragile string match that finds the "Food &
Drink" budget category with a stable reference or predicate: instead of
budget.budget_categories.find { |bc| bc.category.name == "Food & Drink" } use a
fixture/model identity or predicate (e.g., use the fixture object for the food
category or a model predicate like bc.category.food? or compare bc.category.id
to the fixture's id) so the test uses Budget, budget.budget_categories, and the
same category fixture/identifier used elsewhere; keep the rest of the test
(BudgetCategory.any_instance.stubs(:actual_spending) and `@function.call`({}))
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 449bedbf-6ecf-4eeb-8fca-00cb98adf04b

📥 Commits

Reviewing files that changed from the base of the PR and between 89f4249 and 4326f2e.

📒 Files selected for processing (3)
  • app/models/assistant.rb
  • app/models/assistant/function/get_budget.rb
  • test/models/assistant/function/get_budget_test.rb

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4326f2e5e7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread app/models/assistant/function/get_budget.rb Outdated
Comment thread app/models/assistant/function/get_budget.rb
Replace fragile string match on `bc.category.name == "Food & Drink"`
with the `categories(:food_and_drink)` fixture so the test setup
isn't sensitive to category-name translations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wolstad and others added 2 commits May 24, 2026 13:11
`Date.strptime` is lenient about trailing characters, so inputs like
`"2026-05-01"` or `"may-2026foo"` were parsing successfully and being
silently truncated to May 2026. Pre-validate the raw string with anchored
regex patterns for the documented YYYY-MM and MMM-YYYY shapes so
malformed tool arguments raise Assistant::Error instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The helper compared `budget.start_date.month/year` against
`Date.current.month/year` and returned nil whenever the current period
straddled two calendar months — common for families with
`month_start_day != 1` (e.g., May 15–Jun 14 viewed on Jun 1). Replace
the calendar-month check with `budget.current?` and compute remaining
days from `budget.end_date` so the helper works for both standard and
custom periods. This also restores the daily pacing row in the budget
UI for custom-month families.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/models/assistant/function/get_budget.rb (1)

168-172: ⚡ Quick win

Localize invalid-month error text via i18n key.

Line 168 and Line 172 hard-code a user-facing message; this should be translated through i18n and keyed by feature.

Proposed change
-      raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY." if fmt.nil?
+      raise Assistant::Error, I18n.t("assistant.functions.get_budget.invalid_month", month: raw) if fmt.nil?

       Date.strptime(raw, fmt)
     rescue ArgumentError
-      raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY."
+      raise Assistant::Error, I18n.t("assistant.functions.get_budget.invalid_month", month: raw)
     end
# config/locales/en.yml
en:
  assistant:
    functions:
      get_budget:
        invalid_month: "Invalid month: %{month}. Use YYYY-MM or MMM-YYYY."
As per coding guidelines "Organize i18n keys hierarchically by feature... Always use `t()` helper for user-facing strings."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/assistant/function/get_budget.rb` around lines 168 - 172, Replace
the hard-coded error message in the month parsing block with an i18n lookup;
where the code currently raises Assistant::Error with "Invalid month: #{raw}.
Use YYYY-MM or MMM-YYYY.", change both raise sites (the initial nil-check and
the rescued ArgumentError) to call the translation helper (t or I18n.t) using a
feature key like "assistant.functions.get_budget.invalid_month" and pass the
month as an interpolation variable (month: raw) so the user-facing string is
localized.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/models/assistant/function/get_budget.rb`:
- Around line 168-172: Replace the hard-coded error message in the month parsing
block with an i18n lookup; where the code currently raises Assistant::Error with
"Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY.", change both raise sites (the
initial nil-check and the rescued ArgumentError) to call the translation helper
(t or I18n.t) using a feature key like
"assistant.functions.get_budget.invalid_month" and pass the month as an
interpolation variable (month: raw) so the user-facing string is localized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d42e53c-3197-43a7-ac46-f2dd01c37f19

📥 Commits

Reviewing files that changed from the base of the PR and between 3a13c67 and 0c80242.

📒 Files selected for processing (4)
  • app/models/assistant/function/get_budget.rb
  • app/models/budget_category.rb
  • test/models/assistant/function/get_budget_test.rb
  • test/models/budget_category_test.rb

Copy link
Copy Markdown
Collaborator

jjmata commented May 25, 2026

The suggested_daily_spending fix is a real bug — custom month periods spanning two calendar months would return nil when they shouldn't. Good catch. The GetBudget function itself is well-structured. A few things:

Budget.find_or_bootstrap may create records for every requested month

When prior_months: 11 is passed, the function calls find_or_bootstrap for 12 months. If those budgets don't exist yet, they may be created as side effects of an AI query. That means a user asking "how did I do last year?" could silently initialize 11 empty budget records. Worth checking whether find_or_bootstrap is read-only when no budget data exists, or whether a read-only alternative should be used here.

prior clamping duplicates MAX_PRIOR_MONTHS check

prior = [ params["prior_months"].to_i, 0 ].max
prior = [ prior, MAX_PRIOR_MONTHS ].min

The params_schema already sets minimum: 0, maximum: MAX_PRIOR_MONTHS, but since strict_mode? returns false, the schema isn't enforced at the call layer. The Ruby-side clamp is the right safety net — just noting both are needed.

filter_map returns an empty array for invalid months

next unless Budget.budget_date_valid?(start_date, family: family) silently drops months outside the valid range rather than raising. The function's caller (the LLM) may not realize it got fewer months than requested. Returning [] is safe, but an info key in the response explaining "only N months of data available" would help the model generate a better response.

format_money allocates a new Money object per call

def format_money(value)
  Money.new(value || 0, family.currency).format
end

For a prior_months: 11 call this is called dozens of times. Minor, but if Money.new is expensive (e.g. currency lookup), this could add up. number_to_currency with the family's currency settings might be lighter.

Missing test: prior_months capping at MAX_PRIOR_MONTHS

There's a test for the happy path but no test that prior_months: 99 is silently clamped to 11. Given the schema doesn't enforce it in non-strict mode, this edge case is worth a test.


Generated by Claude Code

`prior_months: N` was calling `Budget.find_or_bootstrap` for every
month, which created empty `Budget` rows (and synced `BudgetCategory`
children) as a side effect of an AI query. Only the explicit target
month now bootstraps; prior months use `Budget.find_by` and are
dropped from the response if they don't exist. The response now
includes `months_unavailable: N` so the LLM can phrase a sensible
answer when fewer months come back than requested.

Extract `Budget.period_for(date, family:)` to share the date-bracket
math between `find_or_bootstrap`, `budget_date_valid?`, and the new
read-only path in `get_budget`.

Adds two tests covering the no-bootstrap behavior for prior months
and the `prior_months` clamp at `MAX_PRIOR_MONTHS`. Updates the
existing N+1 sorted-months test to seed prior budgets explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wolstad
Copy link
Copy Markdown
Author

wolstad commented May 25, 2026

@jjmata thanks for the review. Addressed in 37e46bb:

#1 (find_or_bootstrap side effect) — fixed. find_or_bootstrap now runs only for the explicit target month; prior months use Budget.find_by and drop from the response if they don't exist. Extracted Budget.period_for(date, family:) so the read-only path shares date-bracket logic with find_or_bootstrap. Regression test asserts Budget.count doesn't change for a prior_months: 3 call with no existing prior budgets.

#2 (clamp duplication) — agreed, leaving the Ruby clamp as the safety net since strict_mode? is false. No change.

#3 (filter_map silently drops months) — response now includes months_unavailable: N when fewer months come back than were considered, so the LLM knows.

#4 (format_money allocation) — left as-is for consistency with the existing pattern in get_income_statement.rb:70-72 and the other Assistant functions; happy to do a subsystem-wide pass in a follow-up if profiling shows it's a hot spot.

#5 (missing clamp test) — added clamps prior_months above MAX_PRIOR_MONTHS covering the prior_months: 99 case.


@coderabbitai on the i18n nitpick (Invalid month error message at get_budget.rb:168,172): skipping for now. Every other raise Assistant::Error, "..." site across app/models/assistant/** (12 sites in assistant/external/*) uses a hard-coded English string, and config/locales has no assistant.functions.* namespace yet — adding one here in isolation would be a strict one-off. The string is also wrapped by FunctionExecutionError in function_tool_caller.rb:28 before propagating, so it isn't rendered directly to the user. Happy to open a follow-up to migrate the whole Assistant subsystem if maintainers prefer.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

Tip

For best results, initiate chat on the files or code changes.

@wolstad The reasoning for skipping the i18n change is sound — keeping it consistent with the existing 12 hard-coded strings across the subsystem is the right call, especially since the error is never rendered directly to the user. A subsystem-wide migration would be the appropriate place to address all of them together.

Would you like me to open a follow-up GitHub issue to track the Assistant subsystem i18n migration? I can reference this PR and the conversation for context.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/models/assistant/function/get_budget_test.rb (1)

134-134: ⚡ Quick win

Stub the specific instance instead of all instances.

Using any_instance.stubs affects all BudgetCategory instances in the test, not just food_bc. This is broader than necessary and could mask issues if other categories need different actual_spending values.

♻️ Proposed fix to stub only the specific instance
-    BudgetCategory.any_instance.stubs(:actual_spending).returns(150)
+    food_bc.stubs(:actual_spending).returns(150)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/assistant/function/get_budget_test.rb` at line 134, Replace the
broad BudgetCategory.any_instance.stubs(:actual_spending).returns(150) with a
stub on the specific test instance (e.g. stub the local variable food_bc) so
only that BudgetCategory returns 150 for actual_spending; locate the call to
BudgetCategory.any_instance.stubs and change it to stub the specific instance
(food_bc.stubs(:actual_spending).returns(150) or equivalent in your test
framework).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models/assistant/function/get_budget.rb`:
- Around line 68-80: The code currently sets requested = month_starts.count {
|start_date| Budget.budget_date_valid?(start_date, family: family) }, which
undercounts requested slots; change requested to represent the total number of
requested month slots (e.g., requested = month_starts.length) so unavailable =
requested - months.length correctly reflects how many requested months were
omitted; keep the month filtering via Budget.budget_date_valid? and
build_month_payload as-is and still set result[:months_unavailable] when
unavailable > 0.

---

Nitpick comments:
In `@test/models/assistant/function/get_budget_test.rb`:
- Line 134: Replace the broad
BudgetCategory.any_instance.stubs(:actual_spending).returns(150) with a stub on
the specific test instance (e.g. stub the local variable food_bc) so only that
BudgetCategory returns 150 for actual_spending; locate the call to
BudgetCategory.any_instance.stubs and change it to stub the specific instance
(food_bc.stubs(:actual_spending).returns(150) or equivalent in your test
framework).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1c9ad36-6190-4960-915b-6e00cb51fa4e

📥 Commits

Reviewing files that changed from the base of the PR and between 0c80242 and 37e46bb.

📒 Files selected for processing (3)
  • app/models/assistant/function/get_budget.rb
  • app/models/budget.rb
  • test/models/assistant/function/get_budget_test.rb

Comment on lines +68 to +80
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

months_unavailable undercounts when requested months fall outside valid budget range.

Line 68 counts only already-valid months as requested, so asking for more months than the valid window can still report months_unavailable: 0 despite truncation. Count all requested month slots instead.

Suggested fix
-    requested = month_starts.count { |start_date| Budget.budget_date_valid?(start_date, family: family) }
+    requested = month_starts.length
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/assistant/function/get_budget.rb` around lines 68 - 80, The code
currently sets requested = month_starts.count { |start_date|
Budget.budget_date_valid?(start_date, family: family) }, which undercounts
requested slots; change requested to represent the total number of requested
month slots (e.g., requested = month_starts.length) so unavailable = requested -
months.length correctly reflects how many requested months were omitted; keep
the month filtering via Budget.budget_date_valid? and build_month_payload as-is
and still set result[:months_unavailable] when unavailable > 0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Development

Successfully merging this pull request may close these issues.

2 participants