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:
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user