Files
simbarag/blueprints/ynab/models.py
T
ryan 8f02318542 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>
2026-10-12 17:32:38 -04:00

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"