From ceb1d1395dd0c3dfe6885fd1864ed3d99254817d Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 17 Aug 2026 12:53:24 -0400 Subject: [PATCH] Aug 17 - Update with utilities management --- .claude/settings.json | 18 + CLAUDE.md | 49 +- app/__init__.py | 16 +- app/models/__init__.py | 5 + app/models/utility.py | 160 +++++++ app/routes/utilities.py | 533 +++++++++++++++++++++ app/services/utility_service.py | 236 +++++++++ app/templates/base.html | 11 + app/templates/utilities/bill_form.html | 148 ++++++ app/templates/utilities/bills.html | 143 ++++++ app/templates/utilities/detail.html | 280 +++++++++++ app/templates/utilities/index.html | 297 ++++++++++++ app/templates/utilities/link.html | 88 ++++ app/templates/utilities/pay.html | 76 +++ app/templates/utilities/provider_form.html | 148 ++++++ app/templates/utilities/providers.html | 90 ++++ scripts/add_utility_tables.py | 83 ++++ 17 files changed, 2375 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.json create mode 100644 app/models/utility.py create mode 100644 app/routes/utilities.py create mode 100644 app/services/utility_service.py create mode 100644 app/templates/utilities/bill_form.html create mode 100644 app/templates/utilities/bills.html create mode 100644 app/templates/utilities/detail.html create mode 100644 app/templates/utilities/index.html create mode 100644 app/templates/utilities/link.html create mode 100644 app/templates/utilities/pay.html create mode 100644 app/templates/utilities/provider_form.html create mode 100644 app/templates/utilities/providers.html create mode 100644 scripts/add_utility_tables.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5c4df06 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(python -m py_compile app/models/utility.py app/routes/utilities.py app/services/utility_service.py scripts/add_utility_tables.py app/__init__.py app/models/__init__.py)", + "Bash(python -c \"import flask, jinja2; print\\('flask', flask.__version__\\)\")", + "Bash(python -c ' *)", + "Bash(python \"C:/Users/IT/AppData/Local/Temp/claude/c--Users-IT-Desktop-Da-Nguyen-Projects-Personal-Finance-Management/f4f69e14-8713-48c0-9dcc-8466d2c18049/scratchpad/smoke_utilities.py\")", + "Bash(python -m venv venv)", + "Bash(./venv/Scripts/python.exe -m pip install -q --disable-pip-version-check flask flask-sqlalchemy flask-login flask-wtf flask-migrate flask-limiter python-dotenv python-dateutil)", + "Bash(./venv/Scripts/python.exe -c \"import flask_login, flask_wtf, flask_limiter, dateutil; print\\('deps ok'\\)\")", + "Bash(\"C:/Users/IT/AppData/Local/Temp/claude/c--Users-IT-Desktop-Da-Nguyen-Projects-Personal-Finance-Management/f4f69e14-8713-48c0-9dcc-8466d2c18049/scratchpad/venv/Scripts/python.exe\" \"C:/Users/IT/AppData/Local/Temp/claude/c--Users-IT-Desktop-Da-Nguyen-Projects-Personal-Finance-Management/f4f69e14-8713-48c0-9dcc-8466d2c18049/scratchpad/smoke_utilities.py\")", + "Bash(./venv/Scripts/python.exe -m pip install -q --disable-pip-version-check pyotp qrcode groq requests cryptography openpyxl Pillow pdfplumber)", + "Bash(./venv/Scripts/python.exe -m pip install -q --disable-pip-version-check pyotp qrcode groq requests cryptography openpyxl Pillow)", + "Bash(git checkout *)", + "Bash(python \"C:/Users/IT/AppData/Local/Temp/claude/c--Users-IT-Desktop-Da-Nguyen-Projects-Personal-Finance-Management/f4f69e14-8713-48c0-9dcc-8466d2c18049/scratchpad/patch_doc.py\")" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 1c419ee..521b3c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -253,11 +253,35 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a - **Update webhook for existing items** (`POST /plaid/update-webhook`) — calls Plaid `/item/webhook/update` for all active items; "Apply to Existing Items" button shown on Plaid page when URL is configured - **Not in sidebar** — accessed via Settings page only +### 2.20 Utilities (Electricity / Water / Gas / Internet) +- Sidebar link: "Utilities" under Money section; amber badge counts bills due within 7 days +- **Providers** (`/utilities/providers`) — one record per utility company + - Types: electricity, water, gas, internet, phone, trash, other (each with a default icon/color/usage unit) + - Fields: name, type, account number, usage unit, pay-from account, expense category, billing day, color, icon, notes + - `usage_unit` blank = no consumption tracking for that provider (the bill form dims those fields) + - Changing the type on a **new** provider auto-fills unit/icon/color; editing an existing one never overwrites choices + - Archive (hides from dashboard, keeps history) or delete (cascades to bills; payment transactions are left in place) +- **Bills** (`/utilities/bills`) — one record per billing period + - Fields: period start/end, amount, due date, usage, meter start/end, notes + - Usage entered directly **or** derived from meter readings — readings win (`sync_usage_from_meter`) + - Live unit-rate hint on the form; `usage_unit` snapshotted from the provider at entry time + - `UNIQUE (provider_id, period_start)` — duplicate periods rejected with a flash, not a 500 + - Validation: period end ≥ period start; meter end ≥ meter start + - Filters: provider, type, status (unpaid / overdue / paid), year; pagination 30/page +- **Status** is derived, not stored: `paid` → `overdue` (past due) → `due_soon` (≤ 7 days) → `unpaid` +- **Payment — two paths** (both set `utility_bills.transaction_id`): + - **Mark Paid** (`/utilities/bills//pay`) — creates an expense transaction (notes `Utility:`), defaults account/category from the provider, then runs `calc_balance` + the budget alert check + - **Link Existing** (`/utilities/bills//link`) — attaches an already-imported Plaid/Teller/Schwab transaction; candidates are unlinked expenses within 45 days of the due date, closest amount first; linking one transaction to two bills is refused + - **Reopen** (`unpay`) — deletes the transaction **only** if PFM generated it (`Utility:` marker); externally linked ones are left alone. Same rule when deleting a bill. +- **Dashboard** (`/utilities/`) — this month / 12-month average / YTD / unpaid+overdue stat cards, bills-due table, 12-month stacked bar chart by type, spend-by-type breakdown, per-provider cards with period-over-period Δ +- **Provider detail** — latest/average/12-month/usage stats plus a chart toggling Amount / Usage / Unit Rate over 24 months +- Service: `app/services/utility_service.py` — `dashboard_summary`, `monthly_series`, `type_totals`, `provider_summary`, `usage_series`, `candidate_transactions`, `build_payment_transaction`, `is_generated_payment` + --- ## 3. Database Schema (MySQL) -### All 23 Tables +### All 25 Tables ``` users — single user, hashed password, currency/timezone prefs, totp_secret, totp_enabled accounts — bank/wallet accounts (balance managed per provider rules) @@ -282,6 +306,8 @@ schwab_accounts — Schwab account ↔ PFM account mapping, account_hash plaid_items — Plaid item: item_id, access_token (EncryptedText), institution_name, cursor, last_synced_at plaid_accounts — Plaid account ↔ PFM account mapping; cc_due_date, cc_minimum_payment, cc_last_statement_balance, cc_is_overdue plaid_sync_previews — temporary preview data: item_id (UNIQUE), data_json (TEXT), next_cursor +utility_providers — utility company: name, utility_type, usage_unit, default_account_id, category_id, billing_day +utility_bills — one billing period: amount, period, due_date, is_paid, transaction_id, usage_amount, meter readings ``` ### Key Column Notes @@ -298,6 +324,9 @@ plaid_sync_previews — temporary preview data: item_id (UNIQUE), data_json - `users.totp_secret` — base32 TOTP secret (VARCHAR 64); NULL when 2FA disabled - `users.budget_alerts_enabled` — boolean; toggles budget threshold emails (Settings → Profile) - `budgets.alert_sent_80` / `alert_sent_100` — dedup flags so threshold emails/flashes fire once per month per category +- `utility_bills.usage_amount` — mapped to the Python attribute `UtilityBill.usage`; the column is NOT named `usage` because that is a reserved word in MySQL +- `utility_bills.transaction_id` — nullable FK to `transactions.id`; set by both mark-paid and link-existing. A transaction whose notes start with `Utility:` was generated by PFM and is deleted on reopen/bill-delete; anything else is left alone +- `utility_providers.usage_unit` — blank/NULL means the provider has no consumption tracking ### Migration Scripts ``` @@ -305,8 +334,13 @@ scripts/add_investment_account.py — adds investments.account_id column (run scripts/add_security_columns.py — adds totp columns, audit_logs table, widens token columns to TEXT (run once) scripts/add_plaid_tables.py — creates plaid_items, plaid_accounts, plaid_sync_previews tables (run once) scripts/add_log_tables.py — creates audit_logs and app_logs tables (run once; safe to re-run) +scripts/add_utility_tables.py — creates utility_providers and utility_bills tables (run once; safe to re-run) ``` +Prefer `flask db migrate` + `flask db upgrade` where the Alembic chain is healthy — these scripts are the +fallback for schema managed outside the chain. Always read a generated migration before running it: +autogenerate cannot see models missing from `app/models/__init__.py` and will propose dropping their tables. + --- ## 4. Project File Structure (Actual) @@ -339,6 +373,7 @@ pfm/ # /home/pfm/web on server │ │ ├── net_worth_snapshot.py │ │ ├── ai_insight.py │ │ ├── fx_rate.py +│ │ ├── utility.py # UtilityProvider, UtilityBill (+ UTILITY_TYPE_META) │ │ ├── audit_log.py # AuditLog model (action, description, ip_address, timestamp) │ │ ├── app_log.py # AppLog model (timestamp, level, module, message TEXT) │ │ ├── teller_enrollment.py # access_token now EncryptedText (TEXT column) @@ -361,7 +396,8 @@ pfm/ # /home/pfm/web on server │ │ ├── schwab.py # OAuth, mapping, sync, snapshot; audit calls; fallback type 'other' │ │ ├── plaid.py # Link flow, exchange, map, sync preview/confirm, balance, liabilities, resync, disconnect │ │ ├── bank_import.py -│ │ └── logs.py # DB-backed API; purge endpoint; clear truncates DB + file +│ │ ├── logs.py # DB-backed API; purge endpoint; clear truncates DB + file +│ │ └── utilities.py # providers + bills CRUD, mark-paid, link payment, usage API │ │ │ ├── services/ │ │ ├── account_service.py @@ -380,6 +416,7 @@ pfm/ # /home/pfm/web on server │ │ ├── teller_service.py # auto_categorize; correct sign convention; live balance after sync │ │ ├── schwab_service.py # + expanded ACCOUNT_TYPE_MAP; refresh_token_expires_at always reset │ │ ├── plaid_service.py # Link token, exchange, accounts, balances, liabilities, cursor sync, parse, import +│ │ ├── utility_service.py # bill roll-ups, usage/rate trends, payment matching │ │ └── bank_import_service.py │ │ │ ├── templates/ @@ -402,6 +439,8 @@ pfm/ # /home/pfm/web on server │ │ ├── schwab/ # index.html (+ ← Settings back btn), map_accounts.html, preview.html │ │ ├── plaid/ # index.html (+ ← Settings back btn), map_accounts.html, preview.html │ │ ├── logs/index.html # DB-backed viewer; Purge dropdown; ← Settings back btn moved to topbar +│ │ ├── utilities/ # index.html, providers.html, provider_form.html, detail.html, +│ │ │ # bills.html, bill_form.html, pay.html, link.html │ │ └── ... (other templates unchanged) │ │ │ └── utils/ @@ -422,6 +461,7 @@ pfm/ # /home/pfm/web on server │ ├── add_security_columns.py # adds TOTP cols, audit_logs table, widens token cols to TEXT │ ├── add_plaid_tables.py # creates plaid_items, plaid_accounts, plaid_sync_previews │ ├── add_log_tables.py # creates audit_logs + app_logs tables (safe to re-run) +│ ├── add_utility_tables.py # creates utility_providers + utility_bills (safe to re-run) │ └── sync_schwab.py # daily Schwab auto-sync (balance + positions + transactions) │ └── tests/ @@ -549,7 +589,7 @@ sentry-sdk[flask]==2.7.0 ## 12. UI/UX -- **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay; Teller Sync and Schwab Sync **removed** — accessible via Settings only +- **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay; Teller Sync and Schwab Sync **removed** — accessible via Settings only; Utilities sits under Money with an amber badge counting bills due within 7 days (`utility_due_count`, set in the `inject_globals` context processor) - **Mobile responsive**: sidebar goes off-canvas with a dimmed overlay under 769px (topbar toggle button opens/closes it); topbar and main content collapse to full width; tables scroll horizontally via `.table-wrap` / `.pcard.p-0` wrapper classes + `.pfm-table` min-widths, with `.d-mob-none` hiding low-priority columns first; under 576px, button labels hide to icon-only (`.btn-label`) and chart/chat heights are capped (base.html) - **Dark mode**: toggle button in topbar (moon/sun icon); persisted via `localStorage['pfm_dark']`; applied pre-paint via a `data-pfm-dark` attribute to avoid flash-of-light-mode; CSS variable overrides plus targeted `[style*="..."]` overrides for hardcoded inline colors in templates (base.html) - **Charts**: Chart.js 4.x (CDN) @@ -640,7 +680,7 @@ APP_URL=https://pfm.ngodanguyen.tech # used to build links in alert emails --- -## 16. Blueprints Registered (17 total) +## 16. Blueprints Registered (18 total) | Blueprint | Prefix | Key routes | |-----------|--------|------------| @@ -661,6 +701,7 @@ APP_URL=https://pfm.ngodanguyen.tech # used to build links in alert emails | plaid | /plaid | index, create-link-token, exchange-token, map/``, sync/``, sync/confirm, balance/``, liabilities/``, resync/``, disconnect/``, webhook, update-webhook | | bank_import | /bank-import | index, parse (AJAX), import (AJAX) | | logs | /logs | index, api (AJAX), clear (AJAX), download, purge (AJAX) | +| utilities | /utilities | index, providers, providers/new, providers/``, providers/``/edit, providers/``/toggle, providers/``/delete, bills, bills/new, bills/``/edit, bills/``/delete, bills/``/pay, bills/``/link, bills/``/unpay, api/usage/`` | --- diff --git a/app/__init__.py b/app/__init__.py index 2cd65ab..9294d0a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -116,6 +116,7 @@ def create_app(config_name=None): from app.routes.plaid import plaid_bp from app.routes.logs import logs_bp from app.routes.bank_import import bank_import_bp + from app.routes.utilities import utilities_bp app.register_blueprint(health_bp) app.register_blueprint(auth_bp) @@ -134,13 +135,14 @@ def create_app(config_name=None): app.register_blueprint(plaid_bp) app.register_blueprint(logs_bp) app.register_blueprint(bank_import_bp) + app.register_blueprint(utilities_bp) with app.app_context(): from app.models import ( User, Account, Category, Receipt, RecurringRule, Transaction, Budget, Goal, GoalContribution, Investment, InvestmentTransaction, NetWorthSnapshot, - AiInsight, FxRate + AiInsight, FxRate, UtilityProvider, UtilityBill ) from app.models.teller_enrollment import TellerEnrollment, TellerAccount from app.models.schwab_connection import SchwabConnection, SchwabAccount @@ -152,7 +154,7 @@ def create_app(config_name=None): @app.context_processor def inject_globals(): from flask_login import current_user as _u - ctx = {'plaid_review_count': 0} + ctx = {'plaid_review_count': 0, 'utility_due_count': 0} if _u.is_authenticated: try: from app.models.transaction import Transaction as _T @@ -162,6 +164,16 @@ def create_app(config_name=None): ).count() except Exception: pass + try: + from app.models.utility import UtilityBill as _UB, DUE_SOON_DAYS + from datetime import date as _d, timedelta as _td + ctx['utility_due_count'] = _UB.query.filter( + _UB.is_paid == False, + _UB.due_date.isnot(None), + _UB.due_date <= _d.today() + _td(days=DUE_SOON_DAYS), + ).count() + except Exception: + pass return ctx # ── Session idle timeout ────────────────────────────────────────────────── diff --git a/app/models/__init__.py b/app/models/__init__.py index b2b0fbd..2c460e5 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -10,5 +10,10 @@ from app.models.investment import Investment, InvestmentTransaction from app.models.net_worth_snapshot import NetWorthSnapshot from app.models.ai_insight import AiInsight from app.models.fx_rate import FxRate +from app.models.utility import UtilityProvider, UtilityBill from app.models.teller_enrollment import TellerEnrollment, TellerAccount +from app.models.schwab_connection import SchwabConnection, SchwabAccount +from app.models.plaid_item import PlaidItem, PlaidAccount, PlaidSyncPreview +from app.models.app_log import AppLog +from app.models.audit_log import AuditLog diff --git a/app/models/utility.py b/app/models/utility.py new file mode 100644 index 0000000..34aac53 --- /dev/null +++ b/app/models/utility.py @@ -0,0 +1,160 @@ +from app.extensions import db +from datetime import datetime, date + + +# type key -> (label, icon, color, default usage unit) +UTILITY_TYPE_META = { + 'electricity': ('Electricity', 'bi-lightning-charge', '#f59e0b', 'kWh'), + 'water': ('Water', 'bi-droplet', '#0ea5e9', 'm³'), + 'gas': ('Gas', 'bi-fire', '#ef4444', 'therms'), + 'internet': ('Internet', 'bi-wifi', '#6366f1', 'GB'), + 'phone': ('Phone', 'bi-phone', '#8b5cf6', 'GB'), + 'trash': ('Trash', 'bi-trash3', '#64748b', ''), + 'other': ('Other', 'bi-plug', '#94a3b8', ''), +} + +UTILITY_TYPES = list(UTILITY_TYPE_META.keys()) + +# A bill is flagged "due soon" this many days before its due date +DUE_SOON_DAYS = 7 + + +class UtilityProvider(db.Model): + """A utility company / service you receive bills from (PG&E, Comcast, ...).""" + + __tablename__ = 'utility_providers' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + utility_type = db.Column( + db.Enum(*UTILITY_TYPES), + nullable=False, + default='electricity' + ) + account_number = db.Column(db.String(100), nullable=True) # customer/meter account no. + usage_unit = db.Column(db.String(20), nullable=True) # kWh, m³, GB — blank = no usage tracking + + # Where bills get paid from, and what expense category they land in + default_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True) + category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True) + + billing_day = db.Column(db.Integer, nullable=True) # day of month the bill arrives + color = db.Column(db.String(7), default='#8b5cf6') + icon = db.Column(db.String(50), default='bi-lightning-charge') + is_active = db.Column(db.Boolean, default=True) + notes = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + default_account = db.relationship('Account') + category = db.relationship('Category') + bills = db.relationship('UtilityBill', back_populates='provider', + lazy='dynamic', cascade='all, delete-orphan') + + @property + def type_label(self): + return UTILITY_TYPE_META.get(self.utility_type, UTILITY_TYPE_META['other'])[0] + + @property + def tracks_usage(self): + return bool(self.usage_unit) + + def __repr__(self): + return f'' + + +class UtilityBill(db.Model): + """One billing period from a provider — amount, due date, and consumption.""" + + __tablename__ = 'utility_bills' + __table_args__ = ( + db.UniqueConstraint('provider_id', 'period_start', name='uq_utility_bill_period'), + ) + + id = db.Column(db.Integer, primary_key=True) + provider_id = db.Column(db.Integer, db.ForeignKey('utility_providers.id'), nullable=False) + + period_start = db.Column(db.Date, nullable=False, index=True) + period_end = db.Column(db.Date, nullable=False) + amount = db.Column(db.Numeric(15, 2), nullable=False) + due_date = db.Column(db.Date, nullable=True, index=True) + + is_paid = db.Column(db.Boolean, default=False, index=True) + paid_date = db.Column(db.Date, nullable=True) + transaction_id = db.Column(db.Integer, db.ForeignKey('transactions.id'), nullable=True) + + # Consumption. usage is either typed directly or derived from meter readings. + # Column is named usage_amount — USAGE is a reserved word in MySQL. + usage = db.Column('usage_amount', db.Numeric(15, 3), nullable=True) + usage_unit = db.Column(db.String(20), nullable=True) # snapshot of provider unit at entry time + meter_start = db.Column(db.Numeric(15, 3), nullable=True) + meter_end = db.Column(db.Numeric(15, 3), nullable=True) + + notes = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + provider = db.relationship('UtilityProvider', back_populates='bills') + transaction = db.relationship('Transaction') + + # ── derived ────────────────────────────────────────────────────────────── + + @property + def period_label(self): + if self.period_start.year == self.period_end.year: + return f"{self.period_start.strftime('%b %d')} – {self.period_end.strftime('%b %d, %Y')}" + return f"{self.period_start.strftime('%b %d, %Y')} – {self.period_end.strftime('%b %d, %Y')}" + + @property + def period_month(self): + """Month the bill is attributed to, for grouping — the period start month.""" + return self.period_start.strftime('%Y-%m') + + @property + def days_until_due(self): + if not self.due_date or self.is_paid: + return None + return (self.due_date - date.today()).days + + @property + def status(self): + """paid | overdue | due_soon | unpaid""" + if self.is_paid: + return 'paid' + days = self.days_until_due + if days is None: + return 'unpaid' + if days < 0: + return 'overdue' + if days <= DUE_SOON_DAYS: + return 'due_soon' + return 'unpaid' + + @property + def rate_per_unit(self): + """Cost per kWh / m³ / GB for this period, or None when usage isn't tracked.""" + if not self.usage: + return None + u = float(self.usage) + if u <= 0: + return None + return float(self.amount) / u + + @property + def days_in_period(self): + return max((self.period_end - self.period_start).days + 1, 1) + + @property + def daily_cost(self): + return float(self.amount) / self.days_in_period + + def sync_usage_from_meter(self): + """If both meter readings are present, usage is the difference between them.""" + if self.meter_start is not None and self.meter_end is not None: + diff = float(self.meter_end) - float(self.meter_start) + if diff >= 0: + self.usage = diff + return self.usage + + def __repr__(self): + return f'' diff --git a/app/routes/utilities.py b/app/routes/utilities.py new file mode 100644 index 0000000..4aca31e --- /dev/null +++ b/app/routes/utilities.py @@ -0,0 +1,533 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import login_required +from flask_wtf import FlaskForm +from wtforms import (StringField, DecimalField, DateField, SelectField, IntegerField, + TextAreaField, SubmitField) +from wtforms.validators import DataRequired, Optional, NumberRange, Length +from sqlalchemy.exc import IntegrityError +from datetime import date, timedelta +import logging + +from app.extensions import db +from app.models.utility import (UtilityProvider, UtilityBill, + UTILITY_TYPE_META, UTILITY_TYPES) +from app.models.account import Account +from app.models.category import Category +from app.models.transaction import Transaction +from app.services.account_service import calc_balance +from app.services import utility_service + +utilities_bp = Blueprint('utilities', __name__, url_prefix='/utilities') + +log = logging.getLogger('app.utilities') + +UTILITY_COLORS = ['#f59e0b', '#0ea5e9', '#ef4444', '#6366f1', '#8b5cf6', '#10b981', '#ec4899', '#64748b'] +UTILITY_ICONS = [ + 'bi-lightning-charge', 'bi-droplet', 'bi-fire', 'bi-wifi', 'bi-phone', + 'bi-trash3', 'bi-plug', 'bi-thermometer-half', 'bi-tv', 'bi-router', +] + + +def _account_choices(): + accts = Account.query.filter_by(is_active=True).order_by(Account.name).all() + return [('', '— None —')] + [(str(a.id), a.name) for a in accts] + + +def _category_choices(): + cats = (Category.query + .filter(Category.is_active == True, + Category.category_type.in_(['expense', 'both'])) + .order_by(Category.name).all()) + return [('', '— None —')] + [(str(c.id), c.name) for c in cats] + + +def _provider_choices(include_inactive=False): + q = UtilityProvider.query + if not include_inactive: + q = q.filter_by(is_active=True) + provs = q.order_by(UtilityProvider.name).all() + return [(str(p.id), f'{p.name} · {p.type_label}') for p in provs] + + +def _type_defaults(): + """Per-type unit/icon/color suggestions for the provider form's JS.""" + return {k: {'unit': v[3], 'icon': v[1], 'color': v[2]} + for k, v in UTILITY_TYPE_META.items()} + + +def _provider_units(): + """provider id -> usage unit, so the bill form can label consumption fields.""" + return {str(p.id): (p.usage_unit or '') for p in UtilityProvider.query.all()} + + +def _safe_int(value, default=None): + try: + return int(value) + except (TypeError, ValueError): + return default + + +# ── forms ──────────────────────────────────────────────────────────────────── + +class ProviderForm(FlaskForm): + name = StringField('Provider Name', validators=[DataRequired(), Length(1, 100)]) + utility_type = SelectField('Utility Type', validators=[DataRequired()], + choices=[(k, v[0]) for k, v in UTILITY_TYPE_META.items()]) + account_number = StringField('Account Number', validators=[Optional(), Length(max=100)]) + usage_unit = StringField('Usage Unit', validators=[Optional(), Length(max=20)]) + default_account_id = SelectField('Pay From Account', validators=[Optional()]) + category_id = SelectField('Expense Category', validators=[Optional()]) + billing_day = IntegerField('Billing Day', validators=[Optional(), NumberRange(min=1, max=31)]) + color = StringField('Color', default='#8b5cf6') + icon = StringField('Icon', default='bi-lightning-charge') + notes = TextAreaField('Notes', validators=[Optional()]) + submit = SubmitField('Save Provider') + + +class BillForm(FlaskForm): + provider_id = SelectField('Provider', validators=[DataRequired()]) + period_start = DateField('Period Start', validators=[DataRequired()]) + period_end = DateField('Period End', validators=[DataRequired()]) + amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0)], places=2) + due_date = DateField('Due Date', validators=[Optional()]) + usage = DecimalField('Usage', validators=[Optional(), NumberRange(min=0)], places=3) + meter_start = DecimalField('Meter Start', validators=[Optional(), NumberRange(min=0)], places=3) + meter_end = DecimalField('Meter End', validators=[Optional(), NumberRange(min=0)], places=3) + notes = TextAreaField('Notes', validators=[Optional()]) + submit = SubmitField('Save Bill') + + def validate(self, extra_validators=None): + if not super().validate(extra_validators): + return False + ok = True + if self.period_end.data and self.period_start.data and \ + self.period_end.data < self.period_start.data: + self.period_end.errors.append('Period end must be on or after period start.') + ok = False + if self.meter_start.data is not None and self.meter_end.data is not None and \ + self.meter_end.data < self.meter_start.data: + self.meter_end.errors.append('Meter end reading is lower than the start reading.') + ok = False + return ok + + +class PayForm(FlaskForm): + account_id = SelectField('Pay From Account', validators=[DataRequired()]) + category_id = SelectField('Category', validators=[Optional()]) + paid_date = DateField('Payment Date', validators=[DataRequired()], default=date.today) + submit = SubmitField('Mark Paid') + + +# ── index ──────────────────────────────────────────────────────────────────── + +@utilities_bp.route('/') +@login_required +def index(): + providers = (UtilityProvider.query + .filter_by(is_active=True) + .order_by(UtilityProvider.utility_type, UtilityProvider.name) + .all()) + + summary = utility_service.dashboard_summary() + summaries = {p.id: utility_service.provider_summary(p) for p in providers} + + return render_template('utilities/index.html', + providers=providers, + summaries=summaries, + summary=summary, + chart=utility_service.monthly_series(12), + type_totals=utility_service.type_totals(12), + type_meta=UTILITY_TYPE_META) + + +# ── providers ──────────────────────────────────────────────────────────────── + +@utilities_bp.route('/providers') +@login_required +def providers(): + provs = (UtilityProvider.query + .order_by(UtilityProvider.is_active.desc(), + UtilityProvider.utility_type, UtilityProvider.name) + .all()) + counts = {p.id: p.bills.count() for p in provs} + return render_template('utilities/providers.html', providers=provs, counts=counts) + + +@utilities_bp.route('/providers/new', methods=['GET', 'POST']) +@login_required +def provider_new(): + form = ProviderForm() + form.default_account_id.choices = _account_choices() + form.category_id.choices = _category_choices() + + if request.method == 'GET': + # Default to the seeded "Utilities" expense category when it exists + util_cat = Category.query.filter(Category.name == 'Utilities').first() + if util_cat: + form.category_id.data = str(util_cat.id) + + if form.validate_on_submit(): + meta = UTILITY_TYPE_META.get(form.utility_type.data, UTILITY_TYPE_META['other']) + prov = UtilityProvider( + name=form.name.data.strip(), + utility_type=form.utility_type.data, + account_number=(form.account_number.data or '').strip() or None, + usage_unit=(form.usage_unit.data or '').strip() or None, + default_account_id=_safe_int(form.default_account_id.data), + category_id=_safe_int(form.category_id.data), + billing_day=form.billing_day.data, + color=form.color.data or meta[2], + icon=form.icon.data or meta[1], + notes=form.notes.data, + ) + db.session.add(prov) + db.session.commit() + flash(f'Provider "{prov.name}" added.', 'success') + return redirect(url_for('utilities.provider_detail', id=prov.id)) + + return render_template('utilities/provider_form.html', form=form, title='New Provider', + colors=UTILITY_COLORS, icons=UTILITY_ICONS, + type_defaults=_type_defaults()) + + +@utilities_bp.route('/providers//edit', methods=['GET', 'POST']) +@login_required +def provider_edit(id): + prov = db.get_or_404(UtilityProvider, id) + form = ProviderForm(obj=prov) + form.default_account_id.choices = _account_choices() + form.category_id.choices = _category_choices() + + if request.method == 'GET': + form.default_account_id.data = str(prov.default_account_id) if prov.default_account_id else '' + form.category_id.data = str(prov.category_id) if prov.category_id else '' + + if form.validate_on_submit(): + prov.name = form.name.data.strip() + prov.utility_type = form.utility_type.data + prov.account_number = (form.account_number.data or '').strip() or None + prov.usage_unit = (form.usage_unit.data or '').strip() or None + prov.default_account_id = _safe_int(form.default_account_id.data) + prov.category_id = _safe_int(form.category_id.data) + prov.billing_day = form.billing_day.data + prov.color = form.color.data or prov.color + prov.icon = form.icon.data or prov.icon + prov.notes = form.notes.data + db.session.commit() + flash('Provider updated.', 'success') + return redirect(url_for('utilities.provider_detail', id=prov.id)) + + return render_template('utilities/provider_form.html', form=form, title='Edit Provider', + provider=prov, colors=UTILITY_COLORS, icons=UTILITY_ICONS, + type_defaults=_type_defaults()) + + +@utilities_bp.route('/providers//toggle', methods=['POST']) +@login_required +def provider_toggle(id): + prov = db.get_or_404(UtilityProvider, id) + prov.is_active = not prov.is_active + db.session.commit() + flash(f'Provider "{prov.name}" {"reactivated" if prov.is_active else "archived"}.', 'info') + return redirect(url_for('utilities.providers')) + + +@utilities_bp.route('/providers//delete', methods=['POST']) +@login_required +def provider_delete(id): + prov = db.get_or_404(UtilityProvider, id) + count = prov.bills.count() + if count and request.form.get('confirm_bills') != 'yes': + flash(f'"{prov.name}" still has {count} bill(s). Archive it instead, ' + f'or confirm deletion from the provider page.', 'warning') + return redirect(url_for('utilities.providers')) + + name = prov.name + db.session.delete(prov) # cascades to its bills + db.session.commit() + flash(f'Provider "{name}" and {count} bill(s) deleted.', 'info') + return redirect(url_for('utilities.providers')) + + +@utilities_bp.route('/providers/') +@login_required +def provider_detail(id): + prov = db.get_or_404(UtilityProvider, id) + page = _safe_int(request.args.get('page'), 1) or 1 + pagination = (prov.bills + .order_by(UtilityBill.period_start.desc()) + .paginate(page=page, per_page=24, error_out=False)) + + return render_template('utilities/detail.html', + provider=prov, + bills=pagination.items, + pagination=pagination, + stats=utility_service.provider_summary(prov), + series=utility_service.usage_series(prov, 24)) + + +# ── bills ──────────────────────────────────────────────────────────────────── + +@utilities_bp.route('/bills') +@login_required +def bills(): + page = _safe_int(request.args.get('page'), 1) or 1 + provider_id = _safe_int(request.args.get('provider_id')) + utility_type = request.args.get('utility_type') or '' + status = request.args.get('status') or '' + year = _safe_int(request.args.get('year')) + + q = UtilityBill.query.join(UtilityProvider) + + if provider_id: + q = q.filter(UtilityBill.provider_id == provider_id) + if utility_type in UTILITY_TYPES: + q = q.filter(UtilityProvider.utility_type == utility_type) + if year: + q = q.filter(UtilityBill.period_start >= date(year, 1, 1), + UtilityBill.period_start <= date(year, 12, 31)) + if status == 'paid': + q = q.filter(UtilityBill.is_paid == True) + elif status == 'unpaid': + q = q.filter(UtilityBill.is_paid == False) + elif status == 'overdue': + q = q.filter(UtilityBill.is_paid == False, + UtilityBill.due_date.isnot(None), + UtilityBill.due_date < date.today()) + + pagination = (q.order_by(UtilityBill.period_start.desc(), UtilityBill.id.desc()) + .paginate(page=page, per_page=30, error_out=False)) + + years = sorted({row[0].year for row in + db.session.query(UtilityBill.period_start).all()}, reverse=True) + + return render_template('utilities/bills.html', + bills=pagination.items, + pagination=pagination, + providers=UtilityProvider.query.order_by(UtilityProvider.name).all(), + type_meta=UTILITY_TYPE_META, + provider_id=provider_id, utility_type=utility_type, + status=status, year=year, years=years) + + +def _apply_bill_form(bill, form): + bill.provider_id = int(form.provider_id.data) + bill.period_start = form.period_start.data + bill.period_end = form.period_end.data + bill.amount = form.amount.data + bill.due_date = form.due_date.data + bill.meter_start = form.meter_start.data + bill.meter_end = form.meter_end.data + bill.usage = form.usage.data + bill.notes = form.notes.data + + provider = db.session.get(UtilityProvider, bill.provider_id) + bill.usage_unit = provider.usage_unit if provider else None + # Meter readings win over a typed usage figure + bill.sync_usage_from_meter() + + +@utilities_bp.route('/bills/new', methods=['GET', 'POST']) +@login_required +def bill_new(): + form = BillForm() + form.provider_id.choices = _provider_choices() + + if not form.provider_id.choices: + flash('Add a utility provider before recording bills.', 'warning') + return redirect(url_for('utilities.provider_new')) + + preset = _safe_int(request.args.get('provider_id')) + if request.method == 'GET': + if preset: + form.provider_id.data = str(preset) + # Default to last month's billing period + today = date.today() + first_this = today.replace(day=1) + form.period_end.data = first_this - timedelta(days=1) + form.period_start.data = form.period_end.data.replace(day=1) + + if form.validate_on_submit(): + bill = UtilityBill() + _apply_bill_form(bill, form) + db.session.add(bill) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + flash('A bill for that provider and period start already exists.', 'danger') + return render_template('utilities/bill_form.html', form=form, title='New Bill', + provider_units=_provider_units()) + + flash('Bill recorded.', 'success') + if request.form.get('pay_now') == 'yes': + return redirect(url_for('utilities.bill_pay', id=bill.id)) + return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) + + return render_template('utilities/bill_form.html', form=form, title='New Bill', + provider_units=_provider_units()) + + +@utilities_bp.route('/bills//edit', methods=['GET', 'POST']) +@login_required +def bill_edit(id): + bill = db.get_or_404(UtilityBill, id) + form = BillForm(obj=bill) + form.provider_id.choices = _provider_choices(include_inactive=True) + + if request.method == 'GET': + form.provider_id.data = str(bill.provider_id) + + if form.validate_on_submit(): + _apply_bill_form(bill, form) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + flash('A bill for that provider and period start already exists.', 'danger') + return render_template('utilities/bill_form.html', form=form, title='Edit Bill', + bill=bill, provider_units=_provider_units()) + + flash('Bill updated.', 'success') + return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) + + return render_template('utilities/bill_form.html', form=form, title='Edit Bill', + bill=bill, provider_units=_provider_units()) + + +@utilities_bp.route('/bills//delete', methods=['POST']) +@login_required +def bill_delete(id): + bill = db.get_or_404(UtilityBill, id) + provider_id = bill.provider_id + + # Only remove the payment transaction if this feature created it + txn = bill.transaction + drop_txn = utility_service.is_generated_payment(bill) + account_id = txn.account_id if txn else None + + db.session.delete(bill) + if drop_txn: + db.session.delete(txn) + db.session.commit() + + if drop_txn and account_id: + calc_balance(account_id) + flash('Bill and its payment transaction deleted.', 'info') + else: + flash('Bill deleted.', 'info') + return redirect(url_for('utilities.provider_detail', id=provider_id)) + + +# ── payment ────────────────────────────────────────────────────────────────── + +@utilities_bp.route('/bills//pay', methods=['GET', 'POST']) +@login_required +def bill_pay(id): + bill = db.get_or_404(UtilityBill, id) + if bill.is_paid: + flash('That bill is already marked paid.', 'info') + return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) + + form = PayForm() + form.account_id.choices = [c for c in _account_choices() if c[0]] + form.category_id.choices = _category_choices() + + if not form.account_id.choices: + flash('Create an active account before paying bills.', 'warning') + return redirect(url_for('accounts.index')) + + if request.method == 'GET': + prov = bill.provider + if prov.default_account_id: + form.account_id.data = str(prov.default_account_id) + if prov.category_id: + form.category_id.data = str(prov.category_id) + form.paid_date.data = bill.due_date or date.today() + + if form.validate_on_submit(): + txn = utility_service.build_payment_transaction( + bill, + account_id=int(form.account_id.data), + paid_date=form.paid_date.data, + category_id=_safe_int(form.category_id.data), + ) + db.session.flush() # need txn.id before linking + + bill.is_paid = True + bill.paid_date = form.paid_date.data + bill.transaction_id = txn.id + db.session.commit() + + calc_balance(txn.account_id) + log.info('[utilities] bill %s marked paid — txn %s', bill.id, txn.id) + + from app.services.alert_service import check_and_flash_budget_alerts + check_and_flash_budget_alerts(flash) + + flash(f'Bill paid — expense of {bill.amount} recorded.', 'success') + return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) + + return render_template('utilities/pay.html', form=form, bill=bill) + + +@utilities_bp.route('/bills//link', methods=['GET', 'POST']) +@login_required +def bill_link(id): + bill = db.get_or_404(UtilityBill, id) + + if request.method == 'POST': + txn_id = _safe_int(request.form.get('transaction_id')) + txn = db.session.get(Transaction, txn_id) if txn_id else None + if not txn: + flash('Select a transaction to link.', 'warning') + return redirect(url_for('utilities.bill_link', id=bill.id)) + + clash = UtilityBill.query.filter(UtilityBill.transaction_id == txn.id, + UtilityBill.id != bill.id).first() + if clash: + flash('That transaction is already linked to another bill.', 'danger') + return redirect(url_for('utilities.bill_link', id=bill.id)) + + bill.transaction_id = txn.id + bill.is_paid = True + bill.paid_date = txn.date + db.session.commit() + flash('Payment linked.', 'success') + return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) + + return render_template('utilities/link.html', bill=bill, + candidates=utility_service.candidate_transactions(bill)) + + +@utilities_bp.route('/bills//unpay', methods=['POST']) +@login_required +def bill_unpay(id): + bill = db.get_or_404(UtilityBill, id) + txn = bill.transaction + drop_txn = utility_service.is_generated_payment(bill) + account_id = txn.account_id if txn else None + + bill.is_paid = False + bill.paid_date = None + bill.transaction_id = None + if drop_txn: + db.session.delete(txn) + db.session.commit() + + if drop_txn and account_id: + calc_balance(account_id) + flash('Bill reopened — its payment transaction was removed.', 'info') + else: + flash('Bill reopened — the linked transaction was left in place.', 'info') + return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) + + +# ── api ────────────────────────────────────────────────────────────────────── + +@utilities_bp.route('/api/usage/') +@login_required +def api_usage(provider_id): + prov = db.get_or_404(UtilityProvider, provider_id) + months = _safe_int(request.args.get('months'), 24) or 24 + return jsonify(utility_service.usage_series(prov, min(max(months, 3), 60))) diff --git a/app/services/utility_service.py b/app/services/utility_service.py new file mode 100644 index 0000000..8e1af7c --- /dev/null +++ b/app/services/utility_service.py @@ -0,0 +1,236 @@ +""" +Utility Service — bill roll-ups, usage trends, and payment matching. + +All aggregation is done in Python rather than SQL: a household has a few +hundred bills at most, and grouping by billing period in the DB would mean +MySQL-specific date functions. +""" + +from datetime import date, timedelta +from decimal import Decimal +from dateutil.relativedelta import relativedelta +from sqlalchemy import func + +from app.extensions import db +from app.models.utility import UtilityProvider, UtilityBill, UTILITY_TYPE_META +from app.models.transaction import Transaction + + +def _month_keys(months): + """['2026-01', ...] ending with the current month.""" + today = date.today().replace(day=1) + return [(today - relativedelta(months=i)).strftime('%Y-%m') + for i in range(months - 1, -1, -1)] + + +def _pct_change(current, previous): + if previous in (None, 0) or current is None: + return None + return round(((float(current) - float(previous)) / abs(float(previous))) * 100, 1) + + +# ── dashboard ──────────────────────────────────────────────────────────────── + +def dashboard_summary(): + """Headline numbers for the utilities index page.""" + today = date.today() + month_start = today.replace(day=1) + last_month_start = month_start - relativedelta(months=1) + year_start = today.replace(month=1, day=1) + + bills = UtilityBill.query.all() + + this_month = sum(float(b.amount) for b in bills if b.period_start >= month_start) + last_month = sum(float(b.amount) for b in bills + if last_month_start <= b.period_start < month_start) + ytd = sum(float(b.amount) for b in bills if b.period_start >= year_start) + + unpaid = [b for b in bills if not b.is_paid] + overdue = [b for b in unpaid if b.status == 'overdue'] + + # Next bill coming due — unpaid, has a due date, soonest first + upcoming = sorted([b for b in unpaid if b.due_date], key=lambda b: b.due_date) + + # 12-month average of full months (excludes the in-progress current month) + twelve_ago = month_start - relativedelta(months=12) + past = [b for b in bills if twelve_ago <= b.period_start < month_start] + months_span = len({b.period_month for b in past}) or 1 + avg_monthly = sum(float(b.amount) for b in past) / months_span + + return { + 'this_month': this_month, + 'last_month': last_month, + 'month_change_pct': _pct_change(this_month, last_month), + 'ytd': ytd, + 'avg_monthly': avg_monthly, + 'unpaid_count': len(unpaid), + 'unpaid_total': sum(float(b.amount) for b in unpaid), + 'overdue_count': len(overdue), + 'next_due': upcoming[0] if upcoming else None, + 'upcoming': upcoming[:5], + } + + +def monthly_series(months=12): + """ + Stacked bar data: one dataset per utility type, one point per month. + Returns {labels, datasets:[{label, key, color, data}]}. + """ + keys = _month_keys(months) + index = {k: i for i, k in enumerate(keys)} + cutoff = date.today().replace(day=1) - relativedelta(months=months - 1) + + bills = (UtilityBill.query + .join(UtilityProvider) + .filter(UtilityBill.period_start >= cutoff) + .all()) + + buckets = {} + for b in bills: + i = index.get(b.period_month) + if i is None: + continue + t = b.provider.utility_type + buckets.setdefault(t, [0.0] * len(keys))[i] += float(b.amount) + + datasets = [] + for t, meta in UTILITY_TYPE_META.items(): + if t not in buckets: + continue + datasets.append({ + 'label': meta[0], + 'key': t, + 'color': meta[2], + 'data': [round(v, 2) for v in buckets[t]], + }) + + labels = [date(int(k[:4]), int(k[5:]), 1).strftime('%b %y') for k in keys] + return {'labels': labels, 'datasets': datasets} + + +def type_totals(months=12): + """Spend per utility type over the window, biggest first.""" + cutoff = date.today().replace(day=1) - relativedelta(months=months - 1) + rows = (db.session.query( + UtilityProvider.utility_type, + func.coalesce(func.sum(UtilityBill.amount), 0)) + .join(UtilityBill, UtilityBill.provider_id == UtilityProvider.id) + .filter(UtilityBill.period_start >= cutoff) + .group_by(UtilityProvider.utility_type) + .all()) + + out = [] + for t, total in rows: + meta = UTILITY_TYPE_META.get(t, UTILITY_TYPE_META['other']) + out.append({'key': t, 'label': meta[0], 'icon': meta[1], + 'color': meta[2], 'total': float(total)}) + out.sort(key=lambda r: r['total'], reverse=True) + return out + + +# ── per-provider ───────────────────────────────────────────────────────────── + +def provider_summary(provider): + """Latest bill, averages, and period-over-period movement for one provider.""" + bills = provider.bills.order_by(UtilityBill.period_start.desc()).all() + if not bills: + return { + 'latest': None, 'previous': None, 'year_ago': None, + 'bill_count': 0, 'avg_amount': 0, 'avg_usage': None, + 'amount_change_pct': None, 'usage_change_pct': None, + 'yoy_change_pct': None, 'total_12mo': 0, 'unpaid_count': 0, + } + + latest = bills[0] + previous = bills[1] if len(bills) > 1 else None + + # Same period one year earlier (within a 20-day window of the start date) + target = latest.period_start - relativedelta(years=1) + year_ago = next((b for b in bills if abs((b.period_start - target).days) <= 20), None) + + cutoff = date.today() - relativedelta(months=12) + recent = [b for b in bills if b.period_start >= cutoff] + with_usage = [b for b in recent if b.usage] + + return { + 'latest': latest, + 'previous': previous, + 'year_ago': year_ago, + 'bill_count': len(bills), + 'avg_amount': (sum(float(b.amount) for b in recent) / len(recent)) if recent else 0, + 'avg_usage': (sum(float(b.usage) for b in with_usage) / len(with_usage)) if with_usage else None, + 'amount_change_pct': _pct_change(latest.amount, previous.amount) if previous else None, + 'usage_change_pct': (_pct_change(latest.usage, previous.usage) + if previous and latest.usage and previous.usage else None), + 'yoy_change_pct': _pct_change(latest.amount, year_ago.amount) if year_ago else None, + 'total_12mo': sum(float(b.amount) for b in recent), + 'unpaid_count': sum(1 for b in bills if not b.is_paid), + } + + +def usage_series(provider, months=24): + """Amount / usage / unit-rate history for a provider's detail chart.""" + cutoff = date.today() - relativedelta(months=months) + bills = (provider.bills + .filter(UtilityBill.period_start >= cutoff) + .order_by(UtilityBill.period_start.asc()) + .all()) + + return { + 'labels': [b.period_start.strftime('%b %y') for b in bills], + 'amounts': [float(b.amount) for b in bills], + 'usage': [float(b.usage) if b.usage else None for b in bills], + 'rates': [round(b.rate_per_unit, 4) if b.rate_per_unit else None for b in bills], + 'unit': provider.usage_unit or '', + 'has_usage': any(b.usage for b in bills), + } + + +# ── payment matching ───────────────────────────────────────────────────────── + +def candidate_transactions(bill, window_days=45, limit=25): + """ + Expense transactions that plausibly paid this bill: near the due date (or + period end), not already attached to another bill. Closest amount first. + """ + anchor = bill.due_date or bill.period_end + start = anchor - timedelta(days=window_days) + end = anchor + timedelta(days=window_days) + + linked = {row[0] for row in + db.session.query(UtilityBill.transaction_id) + .filter(UtilityBill.transaction_id.isnot(None), + UtilityBill.id != bill.id).all()} + + q = (Transaction.query + .filter(Transaction.transaction_type == 'expense', + Transaction.date >= start, + Transaction.date <= end) + .order_by(Transaction.date.desc())) + + target = float(bill.amount) + rows = [t for t in q.limit(300).all() if t.id not in linked] + rows.sort(key=lambda t: (abs(float(t.amount) - target), abs((t.date - anchor).days))) + return rows[:limit] + + +def build_payment_transaction(bill, account_id, paid_date, category_id=None): + """Create (but don't commit) the expense transaction for a bill payment.""" + provider = bill.provider + txn = Transaction( + account_id=account_id, + category_id=category_id if category_id else provider.category_id, + transaction_type='expense', + amount=bill.amount, + description=f'{provider.name} — {provider.type_label}', + date=paid_date, + notes=f'Utility:{bill.id}', + ) + db.session.add(txn) + return txn + + +def is_generated_payment(bill): + """True when the linked transaction was created by mark-paid (so unpaying may delete it).""" + txn = bill.transaction + return bool(txn and (txn.notes or '').strip().startswith(f'Utility:{bill.id}')) diff --git a/app/templates/base.html b/app/templates/base.html index b60748a..ed26238 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -322,6 +322,17 @@ > Accounts + + Utilities + {% if utility_due_count > 0 %} + {{ utility_due_count }} + {% endif %} + + ← Bills +{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.provider_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.provider_id(class="form-select" + (" is-invalid" if form.provider_id.errors else ""), id="providerSelect") }} + {% for e in form.provider_id.errors %}
{{ e }}
{% endfor %} +
+ +
+
+ {{ form.period_start.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.period_start(class="form-control" + (" is-invalid" if form.period_start.errors else "")) }} + {% for e in form.period_start.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.period_end.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.period_end(class="form-control" + (" is-invalid" if form.period_end.errors else "")) }} + {% for e in form.period_end.errors %}
{{ e }}
{% endfor %} +
+
+ +
+
+ {{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""), placeholder="0.00", id="amountInput") }} +
+ {% for e in form.amount.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.due_date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.due_date(class="form-control") }} +
+
+ + +
+
+ + +
+ +
+
+ {{ form.usage.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.usage(class="form-control", placeholder="0.000", id="usageInput") }} +
+
+ {{ form.meter_start.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.meter_start(class="form-control", placeholder="Previous reading", id="meterStart") }} +
+
+ {{ form.meter_end.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.meter_end(class="form-control" + (" is-invalid" if form.meter_end.errors else ""), placeholder="Current reading", id="meterEnd") }} + {% for e in form.meter_end.errors %}
{{ e }}
{% endfor %} +
+
+ + Enter usage directly, or both meter readings — readings win and fill in usage automatically. + + + +
+ +
+ {{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.notes(class="form-control", rows=2, placeholder="Rate plan, unusual charges, meter notes…") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} + {% if not bill %} + + {% endif %} + Cancel +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/utilities/bills.html b/app/templates/utilities/bills.html new file mode 100644 index 0000000..46d55e0 --- /dev/null +++ b/app/templates/utilities/bills.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} +{% block title %}Utility Bills{% endblock %} +{% block page_title %}Utility Bills{% endblock %} + +{% block extra_css %} +.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; } +{% endblock %} + +{% block topbar_actions %} +← Utilities +New Bill +{% endblock %} + +{% block content %} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+ +{% if bills %} +
+
+ + + + + + + + + + + + + + {% for b in bills %} + + + + + + + + + + {% endfor %} + +
ProviderPeriodDueAmountUsageStatusActions
+
+
+ +
+
+ {{ b.provider.name }} +
{{ b.provider.type_label }}
+
+
+
{{ b.period_label }}{{ b.due_date.strftime('%b %d, %Y') if b.due_date else '—' }}{{ b.amount | currency }} + {% if b.usage %}{{ '%.1f' | format(b.usage | float) }} {{ b.usage_unit or '' }}{% else %}—{% endif %} + + {% if b.status == 'paid' %} + Paid + {% elif b.status == 'overdue' %} + {{ b.days_until_due | abs }}d late + {% elif b.status == 'due_soon' %} + Due in {{ b.days_until_due }}d + {% else %} + Unpaid + {% endif %} + + {% if not b.is_paid %} + Pay + {% endif %} + +
+
+ + {% if pagination.pages > 1 %} +
+ Page {{ pagination.page }} of {{ pagination.pages }}  ·  {{ ((pagination.page-1)*30)+1 }}–{{ [pagination.page*30, pagination.total]|min }} of {{ pagination.total }} +
+ {% if pagination.has_prev %} + ← Prev + {% endif %} + {% if pagination.has_next %} + Next → + {% endif %} +
+
+ {% endif %} +
+{% else %} +
+ +
No bills match
+

Try clearing the filters, or record a new bill.

+ New Bill +
+{% endif %} + +{% endblock %} diff --git a/app/templates/utilities/detail.html b/app/templates/utilities/detail.html new file mode 100644 index 0000000..7530408 --- /dev/null +++ b/app/templates/utilities/detail.html @@ -0,0 +1,280 @@ +{% extends "base.html" %} +{% block title %}{{ provider.name }}{% endblock %} +{% block page_title %}{{ provider.name }}{% endblock %} + +{% block extra_css %} +.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; } +.trend-up { color: #ef4444; } +.trend-down { color: #10b981; } +{% endblock %} + +{% block topbar_actions %} +← Utilities +Edit +Add Bill +{% endblock %} + +{% block content %} + + +
+
+
+ +
+
+
{{ provider.name }}
+
+ {{ provider.type_label }} + {% if provider.account_number %} · Acct {{ provider.account_number }}{% endif %} + {% if provider.default_account %} · Paid from {{ provider.default_account.name }}{% endif %} + {% if provider.billing_day %} · Bills on day {{ provider.billing_day }}{% endif %} +
+
+
+ {% if provider.notes %} +
{{ provider.notes }}
+ {% endif %} +
+ + +
+
+
+
Latest Bill
+
{{ stats.latest.amount | currency if stats.latest else '—' }}
+ {% if stats.amount_change_pct is not none %} + + {{ stats.amount_change_pct | abs }}% vs prev + + {% endif %} +
+
+
+
+
Average Bill
+
{{ stats.avg_amount | currency }}
+ Last 12 months +
+
+
+
+
12-Month Total
+
{{ stats.total_12mo | currency }}
+ {{ stats.bill_count }} bill{{ '' if stats.bill_count == 1 else 's' }} on record +
+
+
+
+
{% if provider.tracks_usage %}Avg Usage{% else %}Year over Year{% endif %}
+ {% if provider.tracks_usage %} +
{{ '%.1f' | format(stats.avg_usage) if stats.avg_usage else '—' }} {{ provider.usage_unit }}
+ {% if stats.usage_change_pct is not none %} + + {{ stats.usage_change_pct | abs }}% latest vs prev + + {% endif %} + {% else %} +
{% if stats.yoy_change_pct is not none %}{{ '+' if stats.yoy_change_pct > 0 else '' }}{{ stats.yoy_change_pct }}%{% else %}—{% endif %}
+ Latest vs same period last year + {% endif %} +
+
+
+ + +{% if series.labels %} +
+
+
Billing History
+ {% if series.has_usage %} +
+ + + +
+ {% endif %} +
+
+
+{% endif %} + + +
+
+
Bills
+
+ + {% if bills %} +
+ + + + + + + {% if provider.tracks_usage %} + + + {% endif %} + + + + + + {% for b in bills %} + + + + + {% if provider.tracks_usage %} + + + {% endif %} + + + + {% endfor %} + +
PeriodDueAmountUsageRateStatusActions
+ {{ b.period_label }} + {% if b.notes %}
{{ b.notes | truncate(60) }}
{% endif %} +
{{ b.due_date.strftime('%b %d, %Y') if b.due_date else '—' }}{{ b.amount | currency }} + {% if b.usage %}{{ '%.1f' | format(b.usage | float) }} {{ b.usage_unit or provider.usage_unit }}{% else %}—{% endif %} + + {% if b.rate_per_unit %}{{ current_user.currency_symbol }}{{ '%.4f' | format(b.rate_per_unit) }}{% else %}—{% endif %} + + {% if b.status == 'paid' %} + Paid + {% if b.paid_date %}
{{ b.paid_date.strftime('%b %d') }}
{% endif %} + {% elif b.status == 'overdue' %} + {{ b.days_until_due | abs }}d late + {% elif b.status == 'due_soon' %} + Due in {{ b.days_until_due }}d + {% else %} + Unpaid + {% endif %} +
+ +
+
+ + {% if pagination.pages > 1 %} +
+ Page {{ pagination.page }} of {{ pagination.pages }}  ·  {{ pagination.total }} bills +
+ {% if pagination.has_prev %} + ← Prev + {% endif %} + {% if pagination.has_next %} + Next → + {% endif %} +
+
+ {% endif %} + + {% else %} +
+ +
No bills recorded
+

Add a bill to start tracking cost{% if provider.tracks_usage %} and usage{% endif %}.

+ Add First Bill +
+ {% endif %} +
+ +{% endblock %} + +{% block extra_js %} +{% if series.labels %} + + +{% endif %} +{% endblock %} diff --git a/app/templates/utilities/index.html b/app/templates/utilities/index.html new file mode 100644 index 0000000..74ff47b --- /dev/null +++ b/app/templates/utilities/index.html @@ -0,0 +1,297 @@ +{% extends "base.html" %} +{% block title %}Utilities{% endblock %} +{% block page_title %}Utilities{% endblock %} + +{% block extra_css %} +.util-card { border-radius: 12px; border: 1px solid var(--border); background: var(--card-bg); padding: 16px 18px; height: 100%; } +.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; } +.trend-up { color: #ef4444; } +.trend-down { color: #10b981; } +{% endblock %} + +{% block topbar_actions %} +All Bills +Providers +New Bill +{% endblock %} + +{% block content %} + + +
+
+
+
+
+
This Month
+
{{ summary.this_month | currency }}
+ {% if summary.month_change_pct is not none %} + + {{ summary.month_change_pct | abs }}% vs last month + + {% endif %} +
+
+
+
+
+
+
+
+
+
Monthly Average
+
{{ summary.avg_monthly | currency }}
+ Last 12 months +
+
+
+
+
+
+
+
+
+
Year to Date
+
{{ summary.ytd | currency }}
+
+
+
+
+
+
+
+
+
+
Unpaid
+
{{ summary.unpaid_total | currency }}
+ + {{ summary.unpaid_count }} bill{{ '' if summary.unpaid_count == 1 else 's' }}{% if summary.overdue_count %} · {{ summary.overdue_count }} overdue{% endif %} + +
+
+
+
+
+
+ + +{% if summary.upcoming %} +
+
Bills Due
+
+ + + + {% for b in summary.upcoming %} + + + + + + + + {% endfor %} + +
ProviderPeriodDueAmountAction
+ + {{ b.period_label }} + {{ b.due_date.strftime('%b %d') }} + {% if b.status == 'overdue' %} + {{ b.days_until_due | abs }}d late + {% elif b.status == 'due_soon' %} + in {{ b.days_until_due }}d + {% endif %} + {{ b.amount | currency }} + Pay +
+
+
+{% endif %} + +
+ +
+
+
Utility Spend — Last 12 Months
+ {% if chart.datasets %} +
+ {% else %} +
No bills recorded yet.
+ {% endif %} +
+
+ + +
+
+
By Type — 12 Months
+ {% if type_totals %} + {% set grand = type_totals | sum(attribute='total') %} + {% for t in type_totals %} +
+
+ {{ t.label }} + {{ t.total | currency }} +
+
+
+
+
+ {% endfor %} + {% else %} +
No data yet.
+ {% endif %} +
+
+
+ + +
+
Providers
+ Add Provider +
+ +{% if providers %} +
+ {% for p in providers %} + {% set s = summaries[p.id] %} +
+
+
+
+
+ +
+
+
{{ p.name }}
+
{{ p.type_label }}{% if s.unpaid_count %} · {{ s.unpaid_count }} unpaid{% endif %}
+
+
+ +
+ + {% if s.latest %} +
+
+
Latest · {{ s.latest.period_start.strftime('%b %Y') }}
+
{{ s.latest.amount | currency }}
+
+
+ {% if s.amount_change_pct is not none %} +
+ {{ s.amount_change_pct | abs }}% +
+
vs prev period
+ {% endif %} +
+
+ + {% if s.latest.usage %} +
+ Usage + {{ '%.1f' | format(s.latest.usage | float) }} {{ s.latest.usage_unit or p.usage_unit }} + {% if s.usage_change_pct is not none %} + ({{ '+' if s.usage_change_pct > 0 else '' }}{{ s.usage_change_pct }}%) + {% endif %} + +
+ {% endif %} + + {% if s.latest.rate_per_unit %} +
+ Unit rate + {{ current_user.currency_symbol }}{{ '%.4f' | format(s.latest.rate_per_unit) }} / {{ s.latest.usage_unit or p.usage_unit }} +
+ {% endif %} + +
+ 12-month total + {{ s.total_12mo | currency }} +
+ + {% if s.latest.status != 'paid' %} + + Mark Latest Paid + + {% else %} + + Add Bill + + {% endif %} + + {% else %} +
No bills yet
+ + Add First Bill + + {% endif %} +
+
+ {% endfor %} +
+{% else %} +
+ +
No utility providers yet
+

Add your electricity, water, gas, and internet providers to track bills and usage.

+ Add First Provider +
+{% endif %} + +{% endblock %} + +{% block extra_js %} +{% if chart.datasets %} + + +{% endif %} +{% endblock %} diff --git a/app/templates/utilities/link.html b/app/templates/utilities/link.html new file mode 100644 index 0000000..1800d2f --- /dev/null +++ b/app/templates/utilities/link.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block title %}Link Payment{% endblock %} +{% block page_title %}Link Existing Payment{% endblock %} + +{% block topbar_actions %} +← {{ bill.provider.name }} +{% endblock %} + +{% block content %} +
+
+ + +
+
+
+ +
+
+
{{ bill.provider.name }}
+
{{ bill.period_label }}{% if bill.due_date %} · due {{ bill.due_date.strftime('%b %d, %Y') }}{% endif %}
+
+
{{ bill.amount | currency }}
+
+
+ +
+
+
Candidate Transactions
+
+ Expenses within 45 days of the due date, closest amount first. Transactions already linked to another bill are hidden. +
+
+ + {% if candidates %} +
+ +
+ + + + + + + + + + + + + + {% for t in candidates %} + {% set diff = (t.amount | float) - (bill.amount | float) %} + + + + + + + + + + {% endfor %} + +
DateDescriptionAccountCategoryAmountΔ
{{ t.date.strftime('%b %d, %Y') }}{{ t.description | truncate(48) }}{{ t.account.name if t.account else '—' }}{{ t.category.name if t.category else '—' }}{{ t.amount | currency }} + {% if diff == 0 %}exact{% else %}{{ '+' if diff > 0 else '' }}{{ '%.2f' | format(diff) }}{% endif %} +
+
+ +
+ + Create New Transaction Instead + Cancel +
+
+ {% else %} +
+ +
No matching transactions
+

Nothing unlinked was found near this bill's due date.

+ Create a Payment Transaction +
+ {% endif %} +
+ +
+
+{% endblock %} diff --git a/app/templates/utilities/pay.html b/app/templates/utilities/pay.html new file mode 100644 index 0000000..2a5a276 --- /dev/null +++ b/app/templates/utilities/pay.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Pay Bill{% endblock %} +{% block page_title %}Mark Bill Paid{% endblock %} + +{% block topbar_actions %} +← {{ bill.provider.name }} +{% endblock %} + +{% block content %} +
+
+ + +
+
+
+ +
+
+
{{ bill.provider.name }}
+
{{ bill.period_label }}
+
+
+
{{ bill.amount | currency }}
+ {% if bill.due_date %} +
Due {{ bill.due_date.strftime('%b %d, %Y') }}
+ {% endif %} +
+
+ {% if bill.usage %} +
+ Usage + {{ '%.1f' | format(bill.usage | float) }} {{ bill.usage_unit or '' }} + {% if bill.rate_per_unit %}· {{ current_user.currency_symbol }}{{ '%.4f' | format(bill.rate_per_unit) }} per {{ bill.usage_unit or 'unit' }}{% endif %} + +
+ {% endif %} +
+ +
+

+ This records an expense transaction for the bill amount and links the two together. + If the payment already came in through a bank sync, + link the existing transaction instead to avoid a duplicate. +

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.account_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.account_id(class="form-select" + (" is-invalid" if form.account_id.errors else "")) }} + {% for e in form.account_id.errors %}
{{ e }}
{% endfor %} +
+ +
+
+ {{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.category_id(class="form-select") }} +
+
+ {{ form.paid_date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.paid_date(class="form-control") }} +
+
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+ +
+
+{% endblock %} diff --git a/app/templates/utilities/provider_form.html b/app/templates/utilities/provider_form.html new file mode 100644 index 0000000..004a871 --- /dev/null +++ b/app/templates/utilities/provider_form.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }}{% endblock %} + +{% block topbar_actions %} +← Providers +{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+
+ {{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="e.g. Pacific Gas & Electric") }} + {% for e in form.name.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.utility_type.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.utility_type(class="form-select", id="typeSelect") }} +
+
+ +
+
+ {{ form.account_number.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.account_number(class="form-control", placeholder="Customer / meter account no.") }} +
+
+ {{ form.billing_day.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.billing_day(class="form-control", type="number", min=1, max=31, placeholder="Day of month") }} + Day the bill usually arrives +
+
+ +
+ {{ form.usage_unit.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.usage_unit(class="form-control", id="unitInput", placeholder="kWh, m³, GB — leave blank for no usage tracking") }} + Bills for this provider will record consumption in this unit. +
+ +
+
+ {{ form.default_account_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.default_account_id(class="form-select") }} +
+
+ {{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.category_id(class="form-select") }} +
+
+ +
+ {{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.notes(class="form-control", rows=2, placeholder="Plan details, contract end date, support number…") }} +
+ + +
+ +
+ {% for c in colors %} + + {% endfor %} +
+ {{ form.color(type="hidden", id="colorInput") }} +
+ + +
+ +
+ {% for icon_val in icons %} + + {% endfor %} +
+ {{ form.icon(type="hidden", id="iconInput") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/utilities/providers.html b/app/templates/utilities/providers.html new file mode 100644 index 0000000..bee639c --- /dev/null +++ b/app/templates/utilities/providers.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}Utility Providers{% endblock %} +{% block page_title %}Utility Providers{% endblock %} + +{% block topbar_actions %} +← Utilities +Add Provider +{% endblock %} + +{% block content %} +{% if providers %} +
+
+ + + + + + + + + + + + + + {% for p in providers %} + + + + + + + + + + {% endfor %} + +
ProviderTypeAccount No.UnitPays FromBillsActions
+
+
+ +
+
+ {{ p.name }} + {% if not p.is_active %}Archived{% endif %} + {% if p.billing_day %}
Bills on day {{ p.billing_day }}
{% endif %} +
+
+
{{ p.type_label }}{{ p.account_number or '—' }}{{ p.usage_unit or '—' }}{{ p.default_account.name if p.default_account else '—' }}{{ counts[p.id] }} + +
+
+
+

+ Archiving hides a provider from the utilities dashboard but keeps its bill history. Deleting removes the bills too — payment transactions created by PFM are left in place. +

+{% else %} +
+ +
No providers yet
+

Add electricity, water, gas, and internet providers to start tracking.

+ Add First Provider +
+{% endif %} +{% endblock %} diff --git a/scripts/add_utility_tables.py b/scripts/add_utility_tables.py new file mode 100644 index 0000000..14ebc99 --- /dev/null +++ b/scripts/add_utility_tables.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Migration: create utility_providers and utility_bills tables. + +Run once: python scripts/add_utility_tables.py +Safe to re-run — uses CREATE TABLE IF NOT EXISTS. + +Prefer `flask db migrate -m "add utility tables"` + `flask db upgrade` if the +Alembic chain on this server is healthy; this script is the fallback for a DB +whose schema is managed outside the chain. +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app import create_app +from app.extensions import db + +app = create_app() + +TABLES = [ + ( + 'utility_providers', + """ + CREATE TABLE IF NOT EXISTS utility_providers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + utility_type ENUM('electricity','water','gas','internet','phone','trash','other') + NOT NULL DEFAULT 'electricity', + account_number VARCHAR(100), + usage_unit VARCHAR(20), + default_account_id INT, + category_id INT, + billing_day INT, + color VARCHAR(7) DEFAULT '#8b5cf6', + icon VARCHAR(50) DEFAULT 'bi-lightning-charge', + is_active TINYINT(1) DEFAULT 1, + notes TEXT, + created_at DATETIME, + updated_at DATETIME, + CONSTRAINT fk_utility_provider_account FOREIGN KEY (default_account_id) REFERENCES accounts (id), + CONSTRAINT fk_utility_provider_category FOREIGN KEY (category_id) REFERENCES categories (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """ + ), + ( + 'utility_bills', + """ + CREATE TABLE IF NOT EXISTS utility_bills ( + id INT AUTO_INCREMENT PRIMARY KEY, + provider_id INT NOT NULL, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + amount DECIMAL(15,2) NOT NULL, + due_date DATE, + is_paid TINYINT(1) DEFAULT 0, + paid_date DATE, + transaction_id INT, + usage_amount DECIMAL(15,3), + usage_unit VARCHAR(20), + meter_start DECIMAL(15,3), + meter_end DECIMAL(15,3), + notes TEXT, + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY uq_utility_bill_period (provider_id, period_start), + INDEX ix_utility_bills_period_start (period_start), + INDEX ix_utility_bills_due_date (due_date), + INDEX ix_utility_bills_is_paid (is_paid), + CONSTRAINT fk_utility_bill_provider FOREIGN KEY (provider_id) REFERENCES utility_providers (id), + CONSTRAINT fk_utility_bill_transaction FOREIGN KEY (transaction_id) REFERENCES transactions (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """ + ), +] + +with app.app_context(): + with db.engine.connect() as conn: + for name, sql in TABLES: + conn.execute(db.text(sql)) + conn.commit() + print(f' {name} — created (or already exists).') + +print('Done.')