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 ───────────────────────────────────────────────