8f02318542
YNAB has no event feed, so keep a local copy in ynab_* tables synced via server_knowledge delta requests: full download on first run, deltas every YNAB_SYNC_INTERVAL seconds and on demand before each tool call, full resync daily. Each sync diffs against the mirror and appends real edits (transactions, assignments, renames) to ynab_changes, the edit history YNAB doesn't keep. Agent YNAB tools now answer from the mirror: budget summary (spending computed from transactions, since YNAB's month activity nets in credit card payment categories), account balances, transaction search with splits expanded, spending reports by category/payee/account/month, category budget, and recent changes. Adds an admin-only read-only ynab_sql tool guarded by a sqlglot allowlist (scope-resolved tables, no dangerous functions), a READ ONLY transaction, a timeout, and a row cap. Replaces utils/ynab_service.py and drops the unused ynab SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
448 lines
16 KiB
Python
448 lines
16 KiB
Python
"""LangChain agent tools that answer YNAB questions from the local mirror.
|
|
|
|
Every tool calls ``ensure_fresh`` first, which runs a cheap delta sync when
|
|
the mirror is more than a couple of minutes old.
|
|
"""
|
|
|
|
import json
|
|
from datetime import date, datetime, timedelta
|
|
from decimal import Decimal
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from langchain.tools import tool
|
|
from tortoise import connections
|
|
|
|
from .sync import ensure_fresh
|
|
|
|
NOT_SYNCED = "YNAB data is unavailable: the budget has not been synced yet."
|
|
|
|
# Every transaction line, with split transactions expanded into their
|
|
# subtransactions so category/payee filters see the real allocation.
|
|
LINES_CTE = """
|
|
WITH lines AS (
|
|
SELECT t.id, t.date, t.amount, t.memo, t.cleared, t.approved,
|
|
t.account_id, t.account_name, t.payee_name, t.category_id,
|
|
t.category_name, t.transfer_account_id
|
|
FROM ynab_transactions t
|
|
WHERE NOT t.deleted AND NOT EXISTS (
|
|
SELECT 1 FROM ynab_subtransactions s
|
|
WHERE s.transaction_id = t.id AND NOT s.deleted
|
|
)
|
|
UNION ALL
|
|
SELECT s.id, t.date, s.amount, COALESCE(s.memo, t.memo), t.cleared, t.approved,
|
|
t.account_id, t.account_name, COALESCE(sp.name, t.payee_name),
|
|
s.category_id, sc.name, s.transfer_account_id
|
|
FROM ynab_subtransactions s
|
|
JOIN ynab_transactions t ON t.id = s.transaction_id
|
|
LEFT JOIN ynab_payees sp ON sp.id = s.payee_id
|
|
LEFT JOIN ynab_categories sc ON sc.id = s.category_id
|
|
WHERE NOT s.deleted AND NOT t.deleted
|
|
)
|
|
"""
|
|
|
|
# Spending = outflows (net of refunds) on budget accounts, excluding transfers
|
|
# (credit card payments, moves to savings) and income.
|
|
SPENDING_FILTER = """
|
|
a.on_budget AND l.transfer_account_id IS NULL
|
|
AND NOT COALESCE(c.internal, false)
|
|
"""
|
|
|
|
GROUP_BY_EXPR = {
|
|
"category": "COALESCE(l.category_name, 'Uncategorized')",
|
|
"category_group": "COALESCE(g.name, 'Uncategorized')",
|
|
"payee": "COALESCE(l.payee_name, 'Unknown payee')",
|
|
"account": "l.account_name",
|
|
"month": "to_char(l.date, 'YYYY-MM')",
|
|
}
|
|
|
|
|
|
def _today() -> date:
|
|
return datetime.now(ZoneInfo("America/New_York")).date()
|
|
|
|
|
|
def _usd(value: Decimal | float | None) -> str:
|
|
value = Decimal(value or 0)
|
|
sign = "-" if value < 0 else ""
|
|
return f"{sign}${abs(value):,.2f}"
|
|
|
|
|
|
def _parse_date(value: str, default: date | None) -> date | None:
|
|
return date.fromisoformat(value) if value else default
|
|
|
|
|
|
def _month_start(month: str) -> date:
|
|
if not month:
|
|
return _today().replace(day=1)
|
|
return date.fromisoformat(month[:7] + "-01")
|
|
|
|
|
|
async def _query(sql: str, *params) -> list[dict]:
|
|
return await connections.get("default").execute_query_dict(sql, list(params))
|
|
|
|
|
|
@tool
|
|
async def ynab_budget_summary(month: str = "") -> str:
|
|
"""Get YNAB budget overview for a month: Ready to Assign, income, assigned, spent, age of money, overspent categories, and net worth.
|
|
|
|
Args:
|
|
month: YYYY-MM (default: current month)
|
|
"""
|
|
state = await ensure_fresh()
|
|
if state is None:
|
|
return NOT_SYNCED
|
|
try:
|
|
start = _month_start(month)
|
|
except ValueError:
|
|
return "Month must be YYYY-MM."
|
|
|
|
months = await _query("SELECT * FROM ynab_months WHERE month = $1", start)
|
|
if not months:
|
|
return f"No YNAB budget data for {start:%Y-%m}."
|
|
m = months[0]
|
|
|
|
overspent = await _query(
|
|
"""SELECT c.name, mc.balance FROM ynab_month_categories mc
|
|
JOIN ynab_categories c ON c.id = mc.category_id
|
|
WHERE mc.month = $1 AND mc.balance < 0 AND NOT mc.deleted
|
|
AND NOT c.deleted AND NOT c.internal
|
|
ORDER BY mc.balance""",
|
|
start,
|
|
)
|
|
# YNAB's month "activity" nets in credit card payment categories, which
|
|
# go up whenever a card is used, so compute spending from transactions.
|
|
next_month = (start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
|
spent = await _query(
|
|
f"""{LINES_CTE}
|
|
SELECT COALESCE(-SUM(l.amount), 0) AS spent FROM lines l
|
|
JOIN ynab_accounts a ON a.id = l.account_id
|
|
LEFT JOIN ynab_categories c ON c.id = l.category_id
|
|
WHERE {SPENDING_FILTER} AND l.date >= $1 AND l.date < $2""",
|
|
start,
|
|
next_month,
|
|
)
|
|
accounts = await _query(
|
|
"""SELECT on_budget, SUM(balance) AS total FROM ynab_accounts
|
|
WHERE NOT deleted AND NOT closed GROUP BY on_budget"""
|
|
)
|
|
by_budget = {r["on_budget"]: r["total"] for r in accounts}
|
|
|
|
lines = [
|
|
f"YNAB budget '{state.budget_name}' for {start:%Y-%m}:",
|
|
f"Ready to Assign: {_usd(m['to_be_budgeted'])}",
|
|
f"Income: {_usd(m['income'])}",
|
|
f"Assigned: {_usd(m['budgeted'])}",
|
|
f"Spent: {_usd(spent[0]['spent'])}",
|
|
]
|
|
if m["age_of_money"] is not None:
|
|
lines.append(f"Age of money: {m['age_of_money']} days")
|
|
if overspent:
|
|
lines.append(
|
|
"Overspent: "
|
|
+ ", ".join(f"{r['name']} ({_usd(r['balance'])})" for r in overspent)
|
|
)
|
|
else:
|
|
lines.append("No overspent categories.")
|
|
lines.append(
|
|
f"Net worth: {_usd(sum(by_budget.values(), Decimal(0)))} "
|
|
f"(budget accounts {_usd(by_budget.get(True))}, "
|
|
f"tracking accounts {_usd(by_budget.get(False))})"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
@tool
|
|
async def ynab_account_balances(include_closed: bool = False) -> str:
|
|
"""List YNAB accounts (checking, savings, credit cards, investments) with current balances.
|
|
|
|
Args:
|
|
include_closed: Also list closed accounts
|
|
"""
|
|
if await ensure_fresh() is None:
|
|
return NOT_SYNCED
|
|
rows = await _query(
|
|
"""SELECT name, type, on_budget, closed, balance, cleared_balance
|
|
FROM ynab_accounts WHERE NOT deleted AND (NOT closed OR $1)
|
|
ORDER BY on_budget DESC, type, name""",
|
|
include_closed,
|
|
)
|
|
if not rows:
|
|
return "No YNAB accounts found."
|
|
lines = []
|
|
for r in rows:
|
|
tags = [r["type"]] + ([] if r["on_budget"] else ["tracking"])
|
|
if r["closed"]:
|
|
tags.append("closed")
|
|
line = f"- {r['name']} [{', '.join(tags)}]: {_usd(r['balance'])}"
|
|
if r["cleared_balance"] != r["balance"]:
|
|
line += f" (cleared {_usd(r['cleared_balance'])})"
|
|
lines.append(line)
|
|
total = sum((r["balance"] for r in rows if not r["closed"]), Decimal(0))
|
|
return "\n".join(lines) + f"\n\nNet worth (open accounts): {_usd(total)}"
|
|
|
|
|
|
@tool
|
|
async def ynab_search_transactions(
|
|
start_date: str = "",
|
|
end_date: str = "",
|
|
payee: str = "",
|
|
category: str = "",
|
|
account: str = "",
|
|
memo: str = "",
|
|
min_amount: float = 0,
|
|
max_amount: float = 0,
|
|
limit: int = 25,
|
|
) -> str:
|
|
"""Search YNAB transactions. Split transactions are expanded into their category lines.
|
|
|
|
Args:
|
|
start_date: YYYY-MM-DD (default: 30 days ago if no other filter, else all time)
|
|
end_date: YYYY-MM-DD (default: today)
|
|
payee: Partial match on payee/store name
|
|
category: Partial match on category name
|
|
account: Partial match on account name
|
|
memo: Partial match on memo
|
|
min_amount: Minimum absolute dollar amount
|
|
max_amount: Maximum absolute dollar amount
|
|
limit: Max transactions to list (default 25, max 100)
|
|
"""
|
|
if await ensure_fresh() is None:
|
|
return NOT_SYNCED
|
|
|
|
has_filter = any([payee, category, account, memo, min_amount, max_amount])
|
|
try:
|
|
default_start = (
|
|
None if has_filter else date.fromordinal(_today().toordinal() - 30)
|
|
)
|
|
start = _parse_date(start_date, default_start)
|
|
end = _parse_date(end_date, _today())
|
|
except ValueError:
|
|
return "Dates must be YYYY-MM-DD."
|
|
|
|
conditions, params = ["l.date <= $1"], [end]
|
|
|
|
def add(condition: str, value) -> None:
|
|
params.append(value)
|
|
conditions.append(condition.replace("?", f"${len(params)}"))
|
|
|
|
if start:
|
|
add("l.date >= ?", start)
|
|
if payee:
|
|
add("l.payee_name ILIKE ?", f"%{payee}%")
|
|
if category:
|
|
add("l.category_name ILIKE ?", f"%{category}%")
|
|
if account:
|
|
add("l.account_name ILIKE ?", f"%{account}%")
|
|
if memo:
|
|
add("l.memo ILIKE ?", f"%{memo}%")
|
|
if min_amount:
|
|
add("abs(l.amount) >= ?", Decimal(str(min_amount)))
|
|
if max_amount:
|
|
add("abs(l.amount) <= ?", Decimal(str(max_amount)))
|
|
|
|
where = " AND ".join(conditions)
|
|
totals = await _query(
|
|
f"""{LINES_CTE}
|
|
SELECT count(*) AS n,
|
|
COALESCE(SUM(amount) FILTER (WHERE amount < 0), 0) AS outflow,
|
|
COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS inflow
|
|
FROM lines l WHERE {where}""",
|
|
*params,
|
|
)
|
|
count = totals[0]["n"]
|
|
range_text = f"{start or 'the beginning'} to {end}"
|
|
if not count:
|
|
return f"No transactions found from {range_text}."
|
|
|
|
limit = min(max(1, limit), 100)
|
|
rows = await _query(
|
|
f"""{LINES_CTE}
|
|
SELECT * FROM lines l WHERE {where}
|
|
ORDER BY l.date DESC, abs(l.amount) DESC LIMIT {limit}""",
|
|
*params,
|
|
)
|
|
lines = [
|
|
f"Found {count} transactions from {range_text}. "
|
|
f"Outflows {_usd(totals[0]['outflow'])}, inflows {_usd(totals[0]['inflow'])}."
|
|
]
|
|
for r in rows:
|
|
line = (
|
|
f"- {r['date']}: {r['payee_name'] or 'Unknown payee'} {_usd(r['amount'])} "
|
|
f"({r['category_name'] or 'Uncategorized'}; {r['account_name']})"
|
|
)
|
|
if r["memo"]:
|
|
line += f" — {r['memo']}"
|
|
if not r["approved"]:
|
|
line += " [unapproved]"
|
|
lines.append(line)
|
|
if count > len(rows):
|
|
lines.append(f"(Showing {len(rows)} most recent of {count})")
|
|
return "\n".join(lines)
|
|
|
|
|
|
@tool
|
|
async def ynab_spending_report(
|
|
group_by: str = "category",
|
|
start_date: str = "",
|
|
end_date: str = "",
|
|
category: str = "",
|
|
payee: str = "",
|
|
top: int = 15,
|
|
) -> str:
|
|
"""Total YNAB spending grouped by category, category_group, payee, account, or month. Use group_by='month' for trends over time.
|
|
|
|
Args:
|
|
group_by: category, category_group, payee, account, or month
|
|
start_date: YYYY-MM-DD (default: first of current month)
|
|
end_date: YYYY-MM-DD (default: today)
|
|
category: Partial match on category name
|
|
payee: Partial match on payee name
|
|
top: Max groups to list (default 15)
|
|
"""
|
|
if await ensure_fresh() is None:
|
|
return NOT_SYNCED
|
|
key = GROUP_BY_EXPR.get(group_by)
|
|
if key is None:
|
|
return f"group_by must be one of: {', '.join(GROUP_BY_EXPR)}."
|
|
try:
|
|
start = _parse_date(start_date, _today().replace(day=1))
|
|
end = _parse_date(end_date, _today())
|
|
except ValueError:
|
|
return "Dates must be YYYY-MM-DD."
|
|
|
|
conditions, params = [SPENDING_FILTER, "l.date >= $1", "l.date <= $2"], [start, end]
|
|
if category:
|
|
params.append(f"%{category}%")
|
|
conditions.append(f"l.category_name ILIKE ${len(params)}")
|
|
if payee:
|
|
params.append(f"%{payee}%")
|
|
conditions.append(f"l.payee_name ILIKE ${len(params)}")
|
|
|
|
order = "key" if group_by == "month" else "spent DESC"
|
|
rows = await _query(
|
|
f"""{LINES_CTE}
|
|
SELECT {key} AS key, -SUM(l.amount) AS spent, count(*) AS n
|
|
FROM lines l
|
|
JOIN ynab_accounts a ON a.id = l.account_id
|
|
LEFT JOIN ynab_categories c ON c.id = l.category_id
|
|
LEFT JOIN ynab_category_groups g ON g.id = c.category_group_id
|
|
WHERE {' AND '.join(conditions)}
|
|
GROUP BY 1 ORDER BY {order}""",
|
|
*params,
|
|
)
|
|
if not rows:
|
|
return f"No spending found from {start} to {end}."
|
|
|
|
total = sum((r["spent"] for r in rows), Decimal(0))
|
|
top = max(1, top)
|
|
shown = rows if group_by == "month" else rows[:top]
|
|
lines = [f"Spending by {group_by} from {start} to {end}: total {_usd(total)}"]
|
|
lines += [f"- {r['key']}: {_usd(r['spent'])} ({r['n']} txns)" for r in shown]
|
|
if len(rows) > len(shown):
|
|
rest = sum((r["spent"] for r in rows[len(shown) :]), Decimal(0))
|
|
lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}")
|
|
if group_by == "month" and len(rows) > 1:
|
|
lines.append(f"Monthly average: {_usd(total / len(rows))}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
@tool
|
|
async def ynab_category_budget(month: str = "", category_group: str = "") -> str:
|
|
"""Get YNAB categories for a month grouped by category group: assigned, activity (negative = spent), available, and goal target. Flags overspending.
|
|
|
|
Args:
|
|
month: YYYY-MM (default: current month)
|
|
category_group: Partial match to show only one group
|
|
"""
|
|
if await ensure_fresh() is None:
|
|
return NOT_SYNCED
|
|
try:
|
|
start = _month_start(month)
|
|
except ValueError:
|
|
return "Month must be YYYY-MM."
|
|
|
|
rows = await _query(
|
|
"""SELECT g.name AS group_name, c.name, c.goal_target,
|
|
mc.budgeted, mc.activity, mc.balance
|
|
FROM ynab_month_categories mc
|
|
JOIN ynab_categories c ON c.id = mc.category_id
|
|
JOIN ynab_category_groups g ON g.id = c.category_group_id
|
|
WHERE mc.month = $1 AND NOT mc.deleted
|
|
AND NOT c.deleted AND NOT c.hidden AND NOT c.internal
|
|
AND NOT g.deleted AND NOT g.hidden
|
|
AND g.name ILIKE $2
|
|
ORDER BY g.name, c.name""",
|
|
start,
|
|
f"%{category_group}%",
|
|
)
|
|
if not rows:
|
|
return f"No YNAB category data for {start:%Y-%m}."
|
|
|
|
lines = [f"Categories for {start:%Y-%m}:"]
|
|
current = None
|
|
for r in rows:
|
|
if not (r["budgeted"] or r["activity"] or r["balance"]):
|
|
continue
|
|
if r["group_name"] != current:
|
|
current = r["group_name"]
|
|
lines.append(f"\n{current}")
|
|
line = (
|
|
f" {r['name']}: assigned {_usd(r['budgeted'])}, "
|
|
f"activity {_usd(r['activity'])}, available {_usd(r['balance'])}"
|
|
)
|
|
if r["goal_target"]:
|
|
line += f", goal {_usd(r['goal_target'])}"
|
|
if r["balance"] < 0:
|
|
line += " [OVERSPENT]"
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
|
|
@tool
|
|
async def ynab_recent_changes(days: int = 7, entity_type: str = "") -> str:
|
|
"""Show what changed in YNAB recently: new/edited/deleted transactions, assignment changes, renamed categories or payees.
|
|
|
|
Args:
|
|
days: Look back this many days (default 7)
|
|
entity_type: Optional filter: transaction, month_category (assignments), category, payee, account
|
|
"""
|
|
if await ensure_fresh() is None:
|
|
return NOT_SYNCED
|
|
rows = await _query(
|
|
"""SELECT synced_at, entity_type, action, label, changes FROM ynab_changes
|
|
WHERE synced_at >= now() - make_interval(days => $1)
|
|
AND ($2 = '' OR entity_type = $2)
|
|
ORDER BY id DESC LIMIT 50""",
|
|
max(1, days),
|
|
entity_type,
|
|
)
|
|
if not rows:
|
|
return f"No YNAB changes recorded in the last {days} days."
|
|
|
|
lines = []
|
|
for r in rows:
|
|
when = r["synced_at"].astimezone(ZoneInfo("America/New_York"))
|
|
changes = r["changes"]
|
|
if isinstance(changes, str):
|
|
changes = json.loads(changes)
|
|
if r["action"] == "updated":
|
|
detail = "; ".join(
|
|
f"{field}: {old} → {new}" for field, (old, new) in changes.items()
|
|
)
|
|
else:
|
|
detail = ""
|
|
entity = r["entity_type"].replace("month_category", "assignment")
|
|
line = f"- {when:%Y-%m-%d %H:%M} {r['action']} {entity} '{r['label']}'"
|
|
lines.append(line + (f": {detail}" if detail else ""))
|
|
note = " (newest 50)" if len(rows) == 50 else ""
|
|
return f"YNAB changes in the last {days} days{note}:\n" + "\n".join(lines)
|
|
|
|
|
|
YNAB_TOOLS = [
|
|
ynab_budget_summary,
|
|
ynab_account_balances,
|
|
ynab_search_transactions,
|
|
ynab_spending_report,
|
|
ynab_category_budget,
|
|
ynab_recent_changes,
|
|
]
|