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:
- Sets
data_is_stale: trueso the UI can say so - Adds receipt spend for the current month on top, reporting
receipt_countandreceipt_totalseparately
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)