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>
174 lines
5.9 KiB
Python
174 lines
5.9 KiB
Python
"""Local Postgres mirror of the YNAB budget.
|
|
|
|
Rows are keyed by YNAB's own ids and kept current by ``blueprints.ynab.sync``
|
|
using YNAB delta requests. Money is stored in dollars with 3 decimal places,
|
|
which is exactly YNAB's milliunits. Deleted YNAB entities are kept with
|
|
``deleted=True`` rather than removed.
|
|
"""
|
|
|
|
from tortoise import fields
|
|
from tortoise.models import Model
|
|
|
|
|
|
def money(**kwargs):
|
|
return fields.DecimalField(max_digits=15, decimal_places=3, **kwargs)
|
|
|
|
|
|
class YnabAccount(Model):
|
|
id = fields.CharField(max_length=36, primary_key=True)
|
|
name = fields.CharField(max_length=255)
|
|
type = fields.CharField(max_length=50)
|
|
on_budget = fields.BooleanField()
|
|
closed = fields.BooleanField()
|
|
note = fields.TextField(null=True)
|
|
balance = money()
|
|
cleared_balance = money()
|
|
uncleared_balance = money()
|
|
last_reconciled_at = fields.DatetimeField(null=True)
|
|
deleted = fields.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
table = "ynab_accounts"
|
|
|
|
|
|
class YnabPayee(Model):
|
|
id = fields.CharField(max_length=36, primary_key=True)
|
|
name = fields.CharField(max_length=500)
|
|
transfer_account_id = fields.CharField(max_length=36, null=True)
|
|
deleted = fields.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
table = "ynab_payees"
|
|
|
|
|
|
class YnabCategoryGroup(Model):
|
|
id = fields.CharField(max_length=36, primary_key=True)
|
|
name = fields.CharField(max_length=255)
|
|
hidden = fields.BooleanField()
|
|
internal = fields.BooleanField(default=False)
|
|
deleted = fields.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
table = "ynab_category_groups"
|
|
|
|
|
|
class YnabCategory(Model):
|
|
id = fields.CharField(max_length=36, primary_key=True)
|
|
category_group_id = fields.CharField(max_length=36, db_index=True)
|
|
name = fields.CharField(max_length=255)
|
|
hidden = fields.BooleanField()
|
|
internal = fields.BooleanField(default=False)
|
|
note = fields.TextField(null=True)
|
|
goal_type = fields.CharField(max_length=20, null=True)
|
|
goal_target = money(null=True)
|
|
goal_target_month = fields.DateField(null=True)
|
|
deleted = fields.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
table = "ynab_categories"
|
|
|
|
|
|
class YnabMonth(Model):
|
|
# ISO month start, e.g. "2026-10-01"
|
|
id = fields.CharField(max_length=10, primary_key=True)
|
|
month = fields.DateField(db_index=True)
|
|
note = fields.TextField(null=True)
|
|
income = money()
|
|
budgeted = money()
|
|
activity = money()
|
|
to_be_budgeted = money()
|
|
age_of_money = fields.IntField(null=True)
|
|
deleted = fields.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
table = "ynab_months"
|
|
|
|
|
|
class YnabMonthCategory(Model):
|
|
"""Per-month category amounts. ``budgeted`` is what YNAB calls "Assigned"."""
|
|
|
|
# "<month>:<category_id>"
|
|
id = fields.CharField(max_length=47, primary_key=True)
|
|
month = fields.DateField(db_index=True)
|
|
category_id = fields.CharField(max_length=36, db_index=True)
|
|
budgeted = money()
|
|
activity = money()
|
|
balance = money()
|
|
deleted = fields.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
table = "ynab_month_categories"
|
|
|
|
|
|
class YnabTransaction(Model):
|
|
id = fields.CharField(max_length=64, primary_key=True)
|
|
date = fields.DateField(db_index=True)
|
|
amount = money()
|
|
memo = fields.TextField(null=True)
|
|
cleared = fields.CharField(max_length=20)
|
|
approved = fields.BooleanField()
|
|
flag_color = fields.CharField(max_length=20, null=True)
|
|
account_id = fields.CharField(max_length=36, db_index=True)
|
|
payee_id = fields.CharField(max_length=36, null=True, db_index=True)
|
|
category_id = fields.CharField(max_length=36, null=True, db_index=True)
|
|
transfer_account_id = fields.CharField(max_length=36, null=True)
|
|
import_payee_name = fields.CharField(max_length=500, null=True)
|
|
# Denormalized from payees/categories/accounts after each sync for easy querying
|
|
account_name = fields.CharField(max_length=255, null=True)
|
|
payee_name = fields.CharField(max_length=500, null=True)
|
|
category_name = fields.CharField(max_length=255, null=True)
|
|
deleted = fields.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
table = "ynab_transactions"
|
|
|
|
|
|
class YnabSubtransaction(Model):
|
|
"""Lines of a split transaction; the parent's category is "Split"."""
|
|
|
|
id = fields.CharField(max_length=64, primary_key=True)
|
|
transaction_id = fields.CharField(max_length=64, db_index=True)
|
|
amount = money()
|
|
memo = fields.TextField(null=True)
|
|
payee_id = fields.CharField(max_length=36, null=True)
|
|
category_id = fields.CharField(max_length=36, null=True, db_index=True)
|
|
transfer_account_id = fields.CharField(max_length=36, null=True)
|
|
deleted = fields.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
table = "ynab_subtransactions"
|
|
|
|
|
|
class YnabChange(Model):
|
|
"""Append-only log of what each sync changed, built by diffing against the mirror.
|
|
|
|
YNAB's API has no history, so this is the only record of edits (e.g. an
|
|
assignment going from $400 to $500). It starts at the first sync.
|
|
"""
|
|
|
|
id = fields.BigIntField(primary_key=True)
|
|
synced_at = fields.DatetimeField(auto_now_add=True, db_index=True)
|
|
server_knowledge = fields.BigIntField()
|
|
entity_type = fields.CharField(max_length=30, db_index=True)
|
|
entity_id = fields.CharField(max_length=64, db_index=True)
|
|
# created / updated / deleted
|
|
action = fields.CharField(max_length=10)
|
|
label = fields.CharField(max_length=500)
|
|
# {field: [old, new]}; for "created", old is null
|
|
changes = fields.JSONField()
|
|
|
|
class Meta:
|
|
table = "ynab_changes"
|
|
|
|
|
|
class YnabSyncState(Model):
|
|
budget_id = fields.CharField(max_length=64, primary_key=True)
|
|
budget_name = fields.CharField(max_length=255, null=True)
|
|
server_knowledge = fields.BigIntField(null=True)
|
|
last_synced_at = fields.DatetimeField(null=True)
|
|
last_full_sync_at = fields.DatetimeField(null=True)
|
|
last_error = fields.TextField(null=True)
|
|
|
|
class Meta:
|
|
table = "ynab_sync_state"
|