From 1e3dbc6a7f657323b7511a9c279eae8ce51818c7 Mon Sep 17 00:00:00 2001 From: TnTBass Date: Mon, 27 Apr 2026 21:32:17 -0600 Subject: [PATCH] Add auto_assign_monthly_targets tool Mirrors YNAB's Auto-Assign -> Monthly Targets button as a single MCP tool call. Iterates every category that has a goal_target set and assigns the goal amount as the budgeted value for the given month. Skips hidden, deleted, and internal groups (Internal Master Category, Credit Card Payments, Hidden Categories). Resolves the 'current' month alias to the first day of the current month before calling the YNAB API. Returns a JSON summary with the resolved month, count of categories assigned, total budgeted dollars, and per-category assignment details. Re-submission of #10 against the new server-package layout (uses plan_id and update_category_for_month). --- src/server/__init__.py | 2 ++ src/server/categories.py | 57 +++++++++++++++++++++++++++++++++++ tests/test_server.py | 64 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/src/server/__init__.py b/src/server/__init__.py index d813fae..2d896f3 100644 --- a/src/server/__init__.py +++ b/src/server/__init__.py @@ -15,6 +15,7 @@ list_categories, get_category, create_category, update_category, create_category_group, update_category_group, get_category_for_month, update_category_for_month, + auto_assign_monthly_targets, ) from src.server.payees import list_payees, get_payee, update_payee from src.server.payee_locations import ( @@ -44,6 +45,7 @@ "list_categories", "get_category", "create_category", "update_category", "create_category_group", "update_category_group", "get_category_for_month", "update_category_for_month", + "auto_assign_monthly_targets", "list_payees", "get_payee", "update_payee", "list_payee_locations", "get_payee_location", "get_payee_locations_by_payee", "list_money_movements", "get_money_movements_for_month", diff --git a/src/server/categories.py b/src/server/categories.py index a9c4942..a103fd7 100644 --- a/src/server/categories.py +++ b/src/server/categories.py @@ -1,3 +1,5 @@ +from datetime import date + from src.server import _shared from src.server._shared import dollars_to_milliunits, serialize, serialize_list @@ -7,6 +9,12 @@ "Pass [] to return all fields. Pass a custom list to override the default." ) +AUTO_ASSIGN_SKIP_GROUPS = { + "Internal Master Category", + "Credit Card Payments", + "Hidden Categories", +} + @_shared.mcp.tool() @_shared.handle_errors @@ -214,3 +222,52 @@ async def update_category_for_month( month, category_id, dollars_to_milliunits(budgeted), plan_id ) return serialize(cat, exclude_fields=exclude_fields) + + +@_shared.mcp.tool() +@_shared.handle_errors +async def auto_assign_monthly_targets(plan_id: str, month: str = "current") -> str: + """Assign budgets to all categories based on their goal targets. + + Mirrors YNAB's Auto-Assign -> Monthly Targets button. For every category + that has a goal_target set, assigns the goal amount as the budgeted value + for the given month. Skips hidden, deleted, and internal groups + (Internal Master Category, Credit Card Payments, Hidden Categories). + + Args: + plan_id: The plan ID (use list_plans to find available IDs) + month: Month in YYYY-MM-DD format (e.g. '2026-05-01') or 'current'. Defaults to current month. + """ + import json + + if month == "current": + today = date.today() + month = today.replace(day=1).strftime("%Y-%m-%d") + + groups = await _shared.cache.get_categories(plan_id) + assignments = [] + for group in groups: + if group.name in AUTO_ASSIGN_SKIP_GROUPS or group.hidden or group.deleted: + continue + for cat in group.categories: + if cat.hidden or cat.deleted or not cat.goal_target: + continue + updated = await _shared.cache.update_category_for_month( + month, cat.id, cat.goal_target, plan_id + ) + assignments.append({ + "name": updated.name, + "group": group.name, + "budgeted": updated.budgeted / 1000, + }) + + total = sum(a["budgeted"] for a in assignments) + return json.dumps( + { + "month": month, + "categories_assigned": len(assignments), + "total_budgeted": round(total, 2), + "assignments": assignments, + }, + indent=2, + ) diff --git a/tests/test_server.py b/tests/test_server.py index c5d40bf..d7306f1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -470,6 +470,70 @@ async def test_converts_dollars_to_milliunits(self, mock_cache): assert call_args[0][2] == 500000 # third positional arg is budgeted in milliunits +class TestAutoAssignMonthlyTargets: + @pytest.mark.asyncio + async def test_assigns_categories_with_goal_targets(self, mock_cache): + from src.server import auto_assign_monthly_targets + + bills = _make_category_group(id="grp-1", name="Bills") + bills.categories = [ + _make_category(id="c1", name="Rent", goal_target=1500000), + _make_category(id="c2", name="Utilities", goal_target=200000), + _make_category(id="c3", name="Unset", goal_target=None), + ] + mock_cache.get_categories = AsyncMock(return_value=[bills]) + mock_cache.update_category_for_month = AsyncMock( + side_effect=lambda month, cid, milli, pid: _make_category( + id=cid, name={"c1": "Rent", "c2": "Utilities"}[cid], budgeted=milli + ) + ) + + result = json.loads(await auto_assign_monthly_targets( + plan_id="bud-1", month="2026-04-01" + )) + + assert result["categories_assigned"] == 2 + assert result["total_budgeted"] == 1700.0 + assert mock_cache.update_category_for_month.call_count == 2 + + @pytest.mark.asyncio + async def test_skips_internal_groups_and_hidden_categories(self, mock_cache): + from src.server import auto_assign_monthly_targets + + internal = _make_category_group(id="grp-i", name="Internal Master Category") + internal.categories = [_make_category(id="ix", goal_target=100000)] + cc = _make_category_group(id="grp-cc", name="Credit Card Payments") + cc.categories = [_make_category(id="cx", goal_target=100000)] + bills = _make_category_group(id="grp-1", name="Bills") + bills.categories = [ + _make_category(id="c1", goal_target=1000000, hidden=True), + _make_category(id="c2", goal_target=500000, deleted=True), + _make_category(id="c3", goal_target=300000), + ] + mock_cache.get_categories = AsyncMock(return_value=[internal, cc, bills]) + mock_cache.update_category_for_month = AsyncMock( + return_value=_make_category(id="c3", budgeted=300000) + ) + + result = json.loads(await auto_assign_monthly_targets( + plan_id="bud-1", month="2026-04-01" + )) + + assert result["categories_assigned"] == 1 + mock_cache.update_category_for_month.assert_called_once() + assert mock_cache.update_category_for_month.call_args[0][1] == "c3" + + @pytest.mark.asyncio + async def test_resolves_current_to_first_of_month(self, mock_cache): + from src.server import auto_assign_monthly_targets + + mock_cache.get_categories = AsyncMock(return_value=[]) + result = json.loads(await auto_assign_monthly_targets(plan_id="bud-1")) + # month is normalized to YYYY-MM-01 + assert result["month"].endswith("-01") + assert len(result["month"]) == 10 + + # ── Payee Tools ───────────────────────────────────────────────