Skip to content

Budget Page

Route: /budget (budget.html) Nav label: Budget Last Updated: 2026-08-11


What It Does

Sets spending targets per category and tracks actuals against them. Two independent budget types:

Type Table Period For
Monthly spend_budgets current calendar month Recurring categories (Food & Grocery, Restaurant, Gas & Fuel)
Annual annual_budgets current calendar year, keyed on (category, year) Lumpy categories that make no sense monthly

A category is monthly or annual, not both. ANNUAL_ONLY_CATS in categories.py decides: Automotive, Donations, Electronics, Gifts, Hockey, Pet Care, Travel.

FIXED_COST_CATS (Mortgage, Utilities) render in a separate fixed-cost card rather than the main table. Mortgage uses a hardcoded MORTGAGE_BUDGET = 5000.0 in app.py, not a spend_budgets row.

Retirement Funds and Stock Funds are excluded from budgeting entirely.

The table is spend_budgets, not budgets

There has never been a budgets table. GET /api/budget checks information_schema for spend_budgets and returns a 500 telling you to run the schema migration if it is missing. annual_budgets is auto-created on first call if absent.


Actuals

Actuals come from bank_transactions for the current calendar month, not the global period selection. type = 'debit', category NOT IN (Transfer, Income).

The stale-statement fallback

Statements arrive weeks after the month starts, so early in a month the budget page would otherwise show near-zero spend against every target. When the newest bank_transactions row predates the current month, the endpoint:

  1. Sets data_is_stale: true so the UI can say so
  2. Adds receipt spend for the current month on top, reporting receipt_count and receipt_total separately

The same fallback runs on the annual endpoint. This means mid-month actuals are receipt-derived and will shift once the statement lands, and a purchase captured by both a receipt and a statement can be double-counted in the window where both exist.


Response Shape

GET /api/budget returns per-category rows plus a summary:

Field Meaning
total_budget Discretionary budgets + fixed costs
total_spent All spend including unbudgeted categories
total_budgeted_spent Spend only in categories that have a budget
categories_over Count over budget
categories_on_track Count with spend, at or under budget
data_is_stale, receipt_count, receipt_total Fallback state described above

API Endpoints

Method Endpoint Purpose
GET /api/budget Monthly budgets + current-month actuals + summary
POST /api/budget Upsert. Body {category, monthly_budget}. Rejects a negative or missing amount with 400
DELETE /api/budget/<path:category> Remove a monthly target
GET /api/budget/annual Annual budgets + YTD actuals for the current year
POST /api/budget/annual Upsert an annual target
DELETE /api/budget/annual/<path:category> Remove an annual target
GET /api/ytd-by-category YTD spend per category with budget comparison. Also feeds the Spending page

The <path:category> converter is deliberate: category names contain & and spaces (Food & Grocery, Personal Care & Wellness) and the default string converter would not match.


Daily Alerts

heezy-budget-alerts runs at 14:00 UTC (9am ET) and POSTs a Discord embed for every category at or above 90% of its monthly budget. It exits 0 silently when no budgets are set or nothing crosses the threshold. The webhook comes from the heezy-finance-discord secret, marked optional: true, so a missing webhook is a no-op rather than a crash loop.

budget_alerts.py keeps its own copy of the taxonomy

It does not import categories.py. It defines its own CATEGORY_ICONS dict and its own SKIP_CATS. This is the exact duplication categories.py exists to prevent, and it will drift the same way the classifier's copy did. Alert embeds for a newly added category will show no icon until someone remembers to edit two files.


Notes

  • Monthly targets are a flat amount repeated every month. No per-month overrides
  • Categories with no budget and no spend are not shown
  • Budget values are numeric(12,2)