mirror of
https://github.com/we-promise/sure.git
synced 2026-05-31 08:19:03 +00:00
* feat(assistant): add get_budget function for budget tracking 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> * test(assistant): use fixture reference for Food & Drink lookup 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> * fix(assistant): enforce strict month format in get_budget `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> * fix(budgets): suggested_daily_spending handles custom-month periods 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> * fix(assistant): make get_budget read-only for prior months `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> --------- Co-authored-by: wolstad <wesleyolstad@protonmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
275 lines
11 KiB
Ruby
275 lines
11 KiB
Ruby
require "test_helper"
|
|
|
|
class BudgetCategoryTest < ActiveSupport::TestCase
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@budget = budgets(:one)
|
|
|
|
# Create parent category with unique name
|
|
@parent_category = Category.create!(
|
|
name: "Test Food & Groceries #{Time.now.to_f}",
|
|
family: @family,
|
|
color: "#4da568",
|
|
lucide_icon: "utensils"
|
|
)
|
|
|
|
# Create subcategories with unique names
|
|
@subcategory_with_limit = Category.create!(
|
|
name: "Test Restaurants #{Time.now.to_f}",
|
|
parent: @parent_category,
|
|
family: @family
|
|
)
|
|
|
|
@subcategory_inheriting = Category.create!(
|
|
name: "Test Groceries #{Time.now.to_f}",
|
|
parent: @parent_category,
|
|
family: @family
|
|
)
|
|
|
|
# Create budget categories
|
|
@parent_budget_category = BudgetCategory.create!(
|
|
budget: @budget,
|
|
category: @parent_category,
|
|
budgeted_spending: 1000,
|
|
currency: "USD"
|
|
)
|
|
|
|
@subcategory_with_limit_bc = BudgetCategory.create!(
|
|
budget: @budget,
|
|
category: @subcategory_with_limit,
|
|
budgeted_spending: 300,
|
|
currency: "USD"
|
|
)
|
|
|
|
@subcategory_inheriting_bc = BudgetCategory.create!(
|
|
budget: @budget,
|
|
category: @subcategory_inheriting,
|
|
budgeted_spending: 0, # Inherits from parent
|
|
currency: "USD"
|
|
)
|
|
end
|
|
|
|
test "subcategory with zero budget inherits from parent" do
|
|
assert @subcategory_inheriting_bc.inherits_parent_budget?
|
|
refute @subcategory_with_limit_bc.inherits_parent_budget?
|
|
refute @parent_budget_category.inherits_parent_budget?
|
|
end
|
|
|
|
test "parent_budget_category returns parent for subcategories" do
|
|
assert_equal @parent_budget_category, @subcategory_inheriting_bc.parent_budget_category
|
|
assert_equal @parent_budget_category, @subcategory_with_limit_bc.parent_budget_category
|
|
assert_nil @parent_budget_category.parent_budget_category
|
|
end
|
|
|
|
test "display_budgeted_spending shows parent budget for inheriting subcategories" do
|
|
assert_equal 1000, @subcategory_inheriting_bc.display_budgeted_spending
|
|
assert_equal 300, @subcategory_with_limit_bc.display_budgeted_spending
|
|
assert_equal 1000, @parent_budget_category.display_budgeted_spending
|
|
end
|
|
|
|
test "inheriting subcategory shares parent available_to_spend" do
|
|
# Mock the actual spending values
|
|
# Parent's actual_spending from income_statement includes all children
|
|
@budget.stubs(:budget_category_actual_spending).with(@parent_budget_category).returns(150)
|
|
@budget.stubs(:budget_category_actual_spending).with(@subcategory_with_limit_bc).returns(100)
|
|
@budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(50)
|
|
|
|
# Parent available calculation:
|
|
# shared_pool = 1000 (parent budget) - 300 (subcategory with limit budget) = 700
|
|
# shared_pool_spending = 150 (total) - 100 (subcategory with limit spending) = 50
|
|
# available = 700 - 50 = 650
|
|
assert_equal 650, @parent_budget_category.available_to_spend
|
|
|
|
# Inheriting subcategory shares parent's available (650)
|
|
assert_equal 650, @subcategory_inheriting_bc.available_to_spend
|
|
|
|
# Subcategory with limit: 300 (its budget) - 100 (its spending) = 200
|
|
assert_equal 200, @subcategory_with_limit_bc.available_to_spend
|
|
end
|
|
|
|
test "percent_of_budget_spent for inheriting subcategory uses parent budget" do
|
|
# Mock spending
|
|
@budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(100)
|
|
|
|
# 100 / 1000 (parent budget) = 10%
|
|
assert_equal 10.0, @subcategory_inheriting_bc.percent_of_budget_spent
|
|
end
|
|
|
|
test "parent with no subcategories works as before" do
|
|
# Create a standalone parent category without subcategories
|
|
standalone_category = Category.create!(
|
|
name: "Test Entertainment #{Time.now.to_f}",
|
|
family: @family,
|
|
color: "#a855f7",
|
|
lucide_icon: "drama"
|
|
)
|
|
|
|
standalone_bc = BudgetCategory.create!(
|
|
budget: @budget,
|
|
category: standalone_category,
|
|
budgeted_spending: 500,
|
|
currency: "USD"
|
|
)
|
|
|
|
# Mock spending
|
|
@budget.stubs(:budget_category_actual_spending).with(standalone_bc).returns(200)
|
|
|
|
# Should work exactly as before: 500 - 200 = 300
|
|
assert_equal 300, standalone_bc.available_to_spend
|
|
assert_equal 40.0, standalone_bc.percent_of_budget_spent
|
|
end
|
|
|
|
test "uncategorized budget category returns no subcategories" do
|
|
uncategorized_bc = BudgetCategory.uncategorized
|
|
uncategorized_bc.budget = @budget
|
|
|
|
# Before the fix, this would return all top-level categories because
|
|
# category.id is nil, causing WHERE parent_id IS NULL to match all roots
|
|
assert_empty uncategorized_bc.subcategories
|
|
end
|
|
|
|
test "parent with only inheriting subcategories shares entire budget" do
|
|
# Set subcategory_with_limit to also inherit
|
|
@subcategory_with_limit_bc.update!(budgeted_spending: 0)
|
|
|
|
# Mock spending
|
|
@budget.stubs(:budget_category_actual_spending).with(@parent_budget_category).returns(200)
|
|
@budget.stubs(:budget_category_actual_spending).with(@subcategory_with_limit_bc).returns(100)
|
|
@budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(100)
|
|
|
|
# All should show same available: 1000 - 200 = 800
|
|
assert_equal 800, @parent_budget_category.available_to_spend
|
|
assert_equal 800, @subcategory_with_limit_bc.available_to_spend
|
|
assert_equal 800, @subcategory_inheriting_bc.available_to_spend
|
|
end
|
|
|
|
test "update_budgeted_spending! preserves positive parent reserve when subcategory becomes individual" do
|
|
@subcategory_inheriting_bc.update_budgeted_spending!(200)
|
|
|
|
assert_equal 1200, @parent_budget_category.reload.budgeted_spending
|
|
assert_equal 200, @subcategory_inheriting_bc.reload.budgeted_spending
|
|
refute @subcategory_inheriting_bc.reload.inherits_parent_budget?
|
|
end
|
|
|
|
test "update_budgeted_spending! lowers parent when subcategory returns to shared" do
|
|
@subcategory_with_limit_bc.update_budgeted_spending!(0)
|
|
|
|
assert_equal 700, @parent_budget_category.reload.budgeted_spending
|
|
assert @subcategory_with_limit_bc.reload.inherits_parent_budget?
|
|
end
|
|
|
|
test "update_budgeted_spending! does not preserve a negative parent reserve" do
|
|
# Create an artificial inconsistent parent total to verify recovery behavior.
|
|
@parent_budget_category.update!(budgeted_spending: 50)
|
|
@subcategory_inheriting_bc.update!(budgeted_spending: 50)
|
|
|
|
@subcategory_with_limit_bc.update_budgeted_spending!(20)
|
|
|
|
assert_equal 70, @parent_budget_category.reload.budgeted_spending
|
|
assert_equal 20, @subcategory_with_limit_bc.reload.budgeted_spending
|
|
assert_equal 50, @subcategory_inheriting_bc.reload.budgeted_spending
|
|
end
|
|
|
|
test "budgeted? returns true only when display_budgeted_spending > 0" do
|
|
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(100)
|
|
assert @subcategory_with_limit_bc.budgeted?
|
|
|
|
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(0)
|
|
refute @subcategory_with_limit_bc.budgeted?
|
|
|
|
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(nil)
|
|
refute @subcategory_with_limit_bc.budgeted?
|
|
end
|
|
|
|
test "unbudgeted_with_spending? is true only when not budgeted and has spending" do
|
|
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
|
|
@subcategory_with_limit_bc.stubs(:actual_spending).returns(10)
|
|
assert @subcategory_with_limit_bc.unbudgeted_with_spending?
|
|
|
|
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
|
|
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
|
|
|
|
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
|
|
@subcategory_with_limit_bc.stubs(:actual_spending).returns(0)
|
|
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
|
|
|
|
@subcategory_with_limit_bc.stubs(:actual_spending).returns(nil)
|
|
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
|
|
end
|
|
|
|
test "over_budget_with_budget? requires both budgeted and over_budget" do
|
|
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
|
|
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
|
|
assert @subcategory_with_limit_bc.over_budget_with_budget?
|
|
|
|
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
|
|
assert_not @subcategory_with_limit_bc.over_budget_with_budget?
|
|
|
|
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
|
|
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
|
|
assert_not @subcategory_with_limit_bc.over_budget_with_budget?
|
|
end
|
|
|
|
test "on_track? is true only when budgeted and not over_budget" do
|
|
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
|
|
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
|
|
assert @subcategory_with_limit_bc.on_track?
|
|
|
|
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
|
|
assert_not @subcategory_with_limit_bc.on_track?
|
|
|
|
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
|
|
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
|
|
assert_not @subcategory_with_limit_bc.on_track?
|
|
end
|
|
|
|
test "any_over_budget? is true if either condition is true" do
|
|
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(true)
|
|
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false)
|
|
assert @subcategory_with_limit_bc.any_over_budget?
|
|
|
|
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false)
|
|
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(true)
|
|
assert @subcategory_with_limit_bc.any_over_budget?
|
|
|
|
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false)
|
|
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false)
|
|
assert_not @subcategory_with_limit_bc.any_over_budget?
|
|
end
|
|
|
|
test "visible_on_track? behavior for different category types" do
|
|
# 1. not on_track => always false
|
|
@subcategory_with_limit_bc.stubs(:on_track?).returns(false)
|
|
assert_not @subcategory_with_limit_bc.visible_on_track?
|
|
|
|
# 2. normal category (not subcategory) => true if on_track
|
|
@parent_budget_category.stubs(:on_track?).returns(true)
|
|
assert @parent_budget_category.visible_on_track?
|
|
|
|
# 3. subcategory inheriting, no spending => hidden
|
|
@subcategory_inheriting_bc.stubs(:on_track?).returns(true)
|
|
@subcategory_inheriting_bc.stubs(:actual_spending).returns(0)
|
|
assert_not @subcategory_inheriting_bc.visible_on_track?
|
|
|
|
# 4. subcategory inheriting, has spending => visible
|
|
@subcategory_inheriting_bc.stubs(:actual_spending).returns(10)
|
|
assert @subcategory_inheriting_bc.visible_on_track?
|
|
end
|
|
|
|
test "suggested_daily_spending uses budget.end_date for custom month periods" do
|
|
@family.update!(month_start_day: 15)
|
|
|
|
# Today (Jun 1) is in the calendar month after the budget period start (May 15).
|
|
# The pre-fix helper compared start_date.month to Date.current.month and returned nil here.
|
|
travel_to Date.new(2026, 6, 1) do
|
|
@budget.update!(start_date: Date.new(2026, 5, 15), end_date: Date.new(2026, 6, 14))
|
|
@parent_budget_category.stubs(:actual_spending).returns(0)
|
|
|
|
suggestion = @parent_budget_category.suggested_daily_spending
|
|
|
|
assert suggestion, "expected suggested_daily_spending when current period spans calendar months"
|
|
assert_equal 14, suggestion[:days_remaining]
|
|
end
|
|
end
|
|
end
|