Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions src/server/categories.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from datetime import date

from src.server import _shared
from src.server._shared import dollars_to_milliunits, serialize, serialize_list

Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Tiny nit: can we hoist this up to the module-level imports next to from datetime import date? Function-local imports always make me do a double-take.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hoisted.


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:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Design thought, not a blocker: if update_category_for_month raises partway through, we end up with a partial budget assignment on YNAB's side and one error response back to the agent, with no easy way to know what got applied. No clean atomic option without a YNAB batch endpoint, but should we wrap each call in a try/except and return a per-category success/failure list in the response? Would make recovery a lot kinder on the agent driving this. Curious what you think.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point. Wrapped each update_category_for_month call in try/except and added a status field per assignment ("ok" or "error"). categories_assigned now counts only successes, and the agent can see exactly which categories failed and why.

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:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Big-picture question before we land this: YNAB's actual "Auto-Assign Monthly Targets" button only fires on monthly-cadence goals (MF, or NEED with goal_cadence == 1). This filter is broader, so a Target Balance goal like "Emergency Fund: $10,000" would get the full $10k dropped into a single month's budget. That's probably going to surprise users who expect the tool to match the UI behavior.

Could we add a goal_type / cadence check here to keep parity? Something like goal_type == "MF" or (goal_type == "NEED" and goal_cadence == 1). Open to your read on it though, was the broader scope intentional?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. The broader scope was not intentional. Added the goal_type/cadence filter to match YNAB's UI: goal_type == "MF" or (goal_type == "NEED" and goal_cadence == 1). TB and other non-monthly goals are now skipped.

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,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Heads up, PR #15 (which I'm hoping to land soon) introduces a milliunits_to_dollars() helper in src/models/common.py that's meant to be the one place we do this conversion. After whichever of us merges second, this hand-rolled / 1000 should swap over to the helper. Nothing to do right now, just flagging the rebase fixup.

@TnTBass TnTBass May 23, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Already done since #15 has landed. Synced upstream and switched to milliunits_to_dollars() in this commit.

})

total = sum(a["budgeted"] for a in assignments)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Two thoughts here: summing dollar floats then rounding can drift on bigger plans, and once #15 lands we'll have the milliunits_to_dollars() helper. Cleaner pattern is to keep a running int total in milliunits and convert once at the end, e.g.:

total_mu += updated.budgeted
...
"total_budgeted": milliunits_to_dollars(total_mu),

Same fix as what I just did to analytics.py in #15 if you want a reference.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Switched to accumulating in milliunits and converting once at the end via milliunits_to_dollars() (which landed with #15, already synced).

return json.dumps(
{
"month": month,
"categories_assigned": len(assignments),
"total_budgeted": round(total, 2),
"assignments": assignments,
},
indent=2,
)
64 changes: 64 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────


Expand Down