Mirror YNAB budget into Postgres with delta sync and change log

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>
This commit is contained in:
2026-10-12 17:32:38 -04:00
parent 3e2a237eb6
commit 8f02318542
21 changed files with 1846 additions and 903 deletions
+249
View File
@@ -0,0 +1,249 @@
"""Tests for the YNAB Postgres mirror: delta application, change log, SQL guard."""
from decimal import Decimal
import pytest
from tortoise import Tortoise
from tortoise.transactions import in_transaction
from blueprints.ynab.models import YnabChange, YnabMonthCategory, YnabTransaction
from blueprints.ynab.sql_tool import format_rows, validate_sql
from blueprints.ynab.sync import _money, apply_budget
ACCOUNT = {
"id": "acct-1",
"name": "Checking",
"type": "checking",
"on_budget": True,
"closed": False,
"note": None,
"balance": 1_000_000,
"cleared_balance": 1_000_000,
"uncleared_balance": 0,
"last_reconciled_at": None,
"deleted": False,
}
PAYEE = {
"id": "payee-1",
"name": "Costco",
"transfer_account_id": None,
"deleted": False,
}
GROUP = {"id": "grp-1", "name": "Bills", "hidden": False, "deleted": False}
CATEGORY = {
"id": "cat-1",
"category_group_id": "grp-1",
"name": "Groceries",
"hidden": False,
"note": None,
"goal_type": None,
"goal_target": 0,
"goal_target_month": None,
"deleted": False,
}
def _txn(**overrides):
txn = {
"id": "txn-1",
"date": "2026-10-03",
"amount": -54_150,
"memo": None,
"cleared": "cleared",
"approved": True,
"flag_color": None,
"account_id": "acct-1",
"payee_id": "payee-1",
"category_id": "cat-1",
"transfer_account_id": None,
"import_payee_name": None,
"deleted": False,
}
return {**txn, **overrides}
def _month(budgeted=400_000, activity=-54_150):
return {
"month": "2026-10-01",
"note": "",
"income": 0,
"budgeted": budgeted,
"activity": activity,
"to_be_budgeted": 0,
"age_of_money": 30,
"deleted": False,
"categories": [
{
**CATEGORY,
"budgeted": budgeted,
"activity": activity,
"balance": budgeted + activity,
}
],
}
def _budget(transactions, month=None):
return {
"accounts": [ACCOUNT],
"payees": [PAYEE],
"category_groups": [GROUP],
"categories": [CATEGORY],
"months": [month or _month()],
"transactions": transactions,
"subtransactions": [],
}
@pytest.fixture
async def db():
await Tortoise.init(
db_url="sqlite://:memory:", modules={"models": ["blueprints.ynab.models"]}
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
async def _apply(budget, knowledge, log_changes=True):
async with in_transaction() as conn:
return await apply_budget(budget, knowledge, conn, log_changes=log_changes)
def test_money_is_exact():
assert _money(-54_150) == Decimal("-54.15")
assert _money(1) == Decimal("0.001")
assert _money(None) is None
async def test_initial_sync_writes_rows_without_logging(db):
counts = await _apply(_budget([_txn()]), 1, log_changes=False)
assert counts["transactions"] == 1
txn = await YnabTransaction.get(id="txn-1")
assert txn.amount == Decimal("-54.15")
# Names are denormalized onto the transaction
assert (txn.payee_name, txn.category_name, txn.account_name) == (
"Costco",
"Groceries",
"Checking",
)
assert await YnabChange.all().count() == 0
async def test_unchanged_delta_is_a_noop(db):
await _apply(_budget([_txn()]), 1, log_changes=False)
counts = await _apply(_budget([_txn()]), 2)
assert not any(counts.values())
assert await YnabChange.all().count() == 0
async def test_edit_logs_field_diff(db):
await _apply(_budget([_txn()]), 1, log_changes=False)
await _apply(_budget([_txn(amount=-60_000, memo="bulk")]), 2)
change = await YnabChange.get(entity_type="transaction")
assert change.action == "updated"
assert change.server_knowledge == 2
assert change.changes == {"amount": [-54.15, -60.0], "memo": [None, "bulk"]}
assert "Costco" in change.label
async def test_assignment_change_is_logged_but_activity_is_not(db):
await _apply(_budget([]), 1, log_changes=False)
# Only activity moves: derived, so not logged
await _apply(_budget([], _month(activity=-80_000)), 2)
assert await YnabChange.filter(entity_type="month_category").count() == 0
await _apply(_budget([], _month(budgeted=500_000, activity=-80_000)), 3)
change = await YnabChange.get(entity_type="month_category")
assert change.changes == {"budgeted": [400.0, 500.0]}
assert change.label == "Groceries 2026-10"
mc = await YnabMonthCategory.get(id="2026-10-01:cat-1")
assert mc.balance == Decimal("420")
async def test_new_and_deleted_transactions(db):
await _apply(_budget([_txn()]), 1, log_changes=False)
await _apply(_budget([_txn(deleted=True), _txn(id="txn-2")]), 2)
actions = {
c.entity_id: c.action
for c in await YnabChange.filter(entity_type="transaction")
}
assert actions == {"txn-1": "deleted", "txn-2": "created"}
assert (await YnabTransaction.get(id="txn-1")).deleted is True
async def test_created_then_deleted_between_syncs_is_not_logged(db):
await _apply(_budget([]), 1, log_changes=False)
await _apply(_budget([_txn(id="txn-9", deleted=True)]), 2)
assert await YnabChange.filter(entity_type="transaction").count() == 0
class TestValidateSql:
@pytest.mark.parametrize(
"query",
[
"SELECT payee_name, sum(amount) FROM ynab_transactions WHERE NOT deleted GROUP BY 1",
"WITH x AS (SELECT * FROM ynab_payees) SELECT * FROM x",
"SELECT date_trunc('month', date) m, count(*) FROM ynab_transactions GROUP BY 1;",
"SELECT * FROM generate_series(1, 3) g",
"WITH RECURSIVE r AS (SELECT 1 n UNION ALL SELECT n + 1 FROM r WHERE n < 3) SELECT * FROM r",
"WITH ynab_payees AS (SELECT * FROM ynab_payees WHERE NOT deleted) SELECT * FROM ynab_payees",
"SELECT * FROM ynab_transactions UNION ALL SELECT * FROM ynab_transactions",
],
)
def test_allows_read_queries(self, query):
assert validate_sql(query) is None
@pytest.mark.parametrize(
"query, error",
[
("DELETE FROM ynab_payees", "Only SELECT"),
("SELECT 1; SELECT 2", "Exactly one"),
("SELECT * FROM users", "not allowed"),
("SELECT * FROM public.conversations", "not allowed"),
("SELECT * FROM pg_catalog.pg_user", "not allowed"),
("SELECT * INTO evil FROM ynab_payees", "read-only"),
("SELECT * FROM ynab_payees FOR UPDATE", "read-only"),
(
"WITH x AS (DELETE FROM ynab_payees RETURNING *) SELECT * FROM x",
"read-only",
),
("SELECT pg_read_file('/etc/passwd')", "pg_read_file"),
("SELECT (SELECT lo_import('/etc/passwd'))", "lo_import"),
("SELECT current_setting('is_superuser')", "current_setting"),
# CTE named like a real table: its body still reads the real table
("WITH users AS (SELECT * FROM users) SELECT * FROM users", "users"),
# CTE defined in a subquery doesn't cover a reference outside it
("SELECT * FROM (WITH users AS (SELECT 1) SELECT 1) x, users", "users"),
("SELECT * FROM ynab_payees WHERE id IN (SELECT id FROM users)", "users"),
],
)
def test_rejects(self, query, error):
result = validate_sql(query)
assert result is not None and error in result
def test_format_rows():
rows = [
{"payee": "Costco", "total": Decimal("-54.150")},
{"payee": None, "total": Decimal("10.000")},
]
assert format_rows(rows) == "payee\ttotal\nCostco\t-54.150\n\t10"
assert format_rows([]) == "Query returned no rows."
async def test_budget_summary_rejects_bad_month(monkeypatch):
from blueprints.ynab import tools
async def fresh():
return object()
monkeypatch.setattr(tools, "ensure_fresh", fresh)
for month in ("October", "2026-13"):
assert await tools.ynab_budget_summary.ainvoke({"month": month}) == (
"Month must be YYYY-MM."
)
-254
View File
@@ -1,254 +0,0 @@
"""Tests for YNAB service data formatting and filtering logic."""
import os
from unittest.mock import MagicMock, patch
import pytest
def _mock_category(
name, budgeted, activity, balance, deleted=False, hidden=False, goal_type=None
):
cat = MagicMock()
cat.name = name
cat.budgeted = budgeted
cat.activity = activity
cat.balance = balance
cat.deleted = deleted
cat.hidden = hidden
cat.goal_type = goal_type
return cat
def _mock_transaction(
var_date, payee_name, category_name, amount, memo="", deleted=False, approved=True
):
txn = MagicMock()
txn.var_date = var_date
txn.payee_name = payee_name
txn.category_name = category_name
txn.amount = amount
txn.memo = memo
txn.deleted = deleted
txn.approved = approved
return txn
@pytest.fixture
def ynab_service():
"""Create a YNABService with mocked API client."""
with patch.dict(
os.environ, {"YNAB_ACCESS_TOKEN": "fake-token", "YNAB_BUDGET_ID": "test-budget"}
):
with patch("utils.ynab_service.ynab") as mock_ynab:
# Mock the configuration and API client chain
mock_ynab.Configuration.return_value = MagicMock()
mock_ynab.ApiClient.return_value = MagicMock()
mock_ynab.PlansApi.return_value = MagicMock()
mock_ynab.TransactionsApi.return_value = MagicMock()
mock_ynab.MonthsApi.return_value = MagicMock()
mock_ynab.CategoriesApi.return_value = MagicMock()
from utils.ynab_service import YNABService
service = YNABService()
yield service
class TestGetBudgetSummary:
def test_calculates_totals(self, ynab_service):
categories = [
_mock_category("Groceries", 500_000, -350_000, 150_000),
_mock_category("Rent", 1_500_000, -1_500_000, 0),
]
mock_month = MagicMock()
mock_month.to_be_budgeted = 200_000
mock_budget = MagicMock()
mock_budget.name = "My Budget"
mock_budget.months = [mock_month]
mock_budget.categories = categories
mock_budget.currency_format = MagicMock(iso_code="USD")
mock_response = MagicMock()
mock_response.data.budget = mock_budget
ynab_service.plans_api.get_plan_by_id.return_value = mock_response
result = ynab_service.get_budget_summary()
assert result["budget_name"] == "My Budget"
assert result["to_be_budgeted"] == 200.0
assert result["total_budgeted"] == 2000.0 # (500k + 1500k) / 1000
assert result["total_activity"] == -1850.0
assert result["currency_format"] == "USD"
def test_skips_deleted_and_hidden(self, ynab_service):
categories = [
_mock_category("Active", 100_000, -50_000, 50_000),
_mock_category("Deleted", 999_000, -999_000, 0, deleted=True),
_mock_category("Hidden", 999_000, -999_000, 0, hidden=True),
]
mock_month = MagicMock()
mock_month.to_be_budgeted = 0
mock_budget = MagicMock()
mock_budget.name = "Budget"
mock_budget.months = [mock_month]
mock_budget.categories = categories
mock_budget.currency_format = None
mock_response = MagicMock()
mock_response.data.budget = mock_budget
ynab_service.plans_api.get_plan_by_id.return_value = mock_response
result = ynab_service.get_budget_summary()
assert result["total_budgeted"] == 100.0
assert result["currency_format"] == "USD" # Default fallback
class TestGetTransactions:
def test_filters_by_date_range(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
_mock_transaction("2026-01-15", "Gas", "Transport", -40_000),
_mock_transaction(
"2026-02-01", "Store", "Groceries", -30_000
), # Out of range
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01", end_date="2026-01-31"
)
assert result["count"] == 2
assert result["total_amount"] == -65.0
def test_filters_by_category(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
_mock_transaction("2026-01-06", "Gas", "Transport", -40_000),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01",
end_date="2026-01-31",
category_name="groceries", # Case insensitive
)
assert result["count"] == 1
assert result["transactions"][0]["category"] == "Groceries"
def test_filters_by_payee(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Whole Foods", "Groceries", -25_000),
_mock_transaction("2026-01-06", "Shell Gas", "Transport", -40_000),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01",
end_date="2026-01-31",
payee_name="whole",
)
assert result["count"] == 1
assert result["transactions"][0]["payee"] == "Whole Foods"
def test_skips_deleted(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
_mock_transaction("2026-01-06", "Deleted", "Other", -10_000, deleted=True),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01", end_date="2026-01-31"
)
assert result["count"] == 1
def test_converts_milliunits(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Store", "Groceries", -12_340),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01", end_date="2026-01-31"
)
assert result["transactions"][0]["amount"] == -12.34
def test_sorts_by_date_descending(self, ynab_service):
transactions = [
_mock_transaction("2026-01-01", "A", "Cat", -10_000),
_mock_transaction("2026-01-15", "B", "Cat", -20_000),
_mock_transaction("2026-01-10", "C", "Cat", -30_000),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01", end_date="2026-01-31"
)
dates = [t["date"] for t in result["transactions"]]
assert dates == sorted(dates, reverse=True)
class TestGetCategorySpending:
def test_month_format_normalization(self, ynab_service):
"""Passing YYYY-MM should be normalized to YYYY-MM-01."""
categories = [_mock_category("Food", 100_000, -50_000, 50_000)]
mock_month = MagicMock()
mock_month.categories = categories
mock_month.to_be_budgeted = 0
mock_response = MagicMock()
mock_response.data.month = mock_month
ynab_service.months_api.get_plan_month.return_value = mock_response
result = ynab_service.get_category_spending("2026-03")
assert result["month"] == "2026-03"
def test_identifies_overspent(self, ynab_service):
categories = [
_mock_category("Dining", 200_000, -300_000, -100_000), # Overspent
_mock_category("Groceries", 500_000, -400_000, 100_000), # Fine
]
mock_month = MagicMock()
mock_month.categories = categories
mock_month.to_be_budgeted = 0
mock_response = MagicMock()
mock_response.data.month = mock_month
ynab_service.months_api.get_plan_month.return_value = mock_response
result = ynab_service.get_category_spending("2026-03")
assert len(result["overspent_categories"]) == 1
assert result["overspent_categories"][0]["name"] == "Dining"
assert result["overspent_categories"][0]["overspent_by"] == 100.0