Aug 17 - Update with utilities management

This commit is contained in:
2026-08-17 12:53:24 -04:00
parent 790eb9894e
commit ceb1d1395d
17 changed files with 2375 additions and 6 deletions
+18
View File
@@ -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\")"
]
}
}
+45 -4
View File
@@ -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 - **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 - **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/<id>/pay`) — creates an expense transaction (notes `Utility:<bill_id>`), defaults account/category from the provider, then runs `calc_balance` + the budget alert check
- **Link Existing** (`/utilities/bills/<id>/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:<id>` 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) ## 3. Database Schema (MySQL)
### All 23 Tables ### All 25 Tables
``` ```
users — single user, hashed password, currency/timezone prefs, totp_secret, totp_enabled users — single user, hashed password, currency/timezone prefs, totp_secret, totp_enabled
accounts — bank/wallet accounts (balance managed per provider rules) 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_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_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 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 ### 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.totp_secret` — base32 TOTP secret (VARCHAR 64); NULL when 2FA disabled
- `users.budget_alerts_enabled` — boolean; toggles budget threshold emails (Settings → Profile) - `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 - `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:<bill_id>` 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 ### 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_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_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_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) ## 4. Project File Structure (Actual)
@@ -339,6 +373,7 @@ pfm/ # /home/pfm/web on server
│ │ ├── net_worth_snapshot.py │ │ ├── net_worth_snapshot.py
│ │ ├── ai_insight.py │ │ ├── ai_insight.py
│ │ ├── fx_rate.py │ │ ├── fx_rate.py
│ │ ├── utility.py # UtilityProvider, UtilityBill (+ UTILITY_TYPE_META)
│ │ ├── audit_log.py # AuditLog model (action, description, ip_address, timestamp) │ │ ├── audit_log.py # AuditLog model (action, description, ip_address, timestamp)
│ │ ├── app_log.py # AppLog model (timestamp, level, module, message TEXT) │ │ ├── app_log.py # AppLog model (timestamp, level, module, message TEXT)
│ │ ├── teller_enrollment.py # access_token now EncryptedText (TEXT column) │ │ ├── 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' │ │ ├── schwab.py # OAuth, mapping, sync, snapshot; audit calls; fallback type 'other'
│ │ ├── plaid.py # Link flow, exchange, map, sync preview/confirm, balance, liabilities, resync, disconnect │ │ ├── plaid.py # Link flow, exchange, map, sync preview/confirm, balance, liabilities, resync, disconnect
│ │ ├── bank_import.py │ │ ├── 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/ │ ├── services/
│ │ ├── account_service.py │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ └── bank_import_service.py
│ │ │ │
│ ├── templates/ │ ├── templates/
@@ -402,6 +439,8 @@ pfm/ # /home/pfm/web on server
│ │ ├── schwab/ # index.html (+ ← Settings back btn), map_accounts.html, preview.html │ │ ├── schwab/ # index.html (+ ← Settings back btn), map_accounts.html, preview.html
│ │ ├── plaid/ # 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 │ │ ├── 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) │ │ └── ... (other templates unchanged)
│ │ │ │
│ └── utils/ │ └── 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_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_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_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) │ └── sync_schwab.py # daily Schwab auto-sync (balance + positions + transactions)
└── tests/ └── tests/
@@ -549,7 +589,7 @@ sentry-sdk[flask]==2.7.0
## 12. UI/UX ## 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) - **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) - **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) - **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 | | 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/`<id>`, sync/`<id>`, sync/confirm, balance/`<pa_id>`, liabilities/`<id>`, resync/`<id>`, disconnect/`<id>`, webhook, update-webhook | | plaid | /plaid | index, create-link-token, exchange-token, map/`<id>`, sync/`<id>`, sync/confirm, balance/`<pa_id>`, liabilities/`<id>`, resync/`<id>`, disconnect/`<id>`, webhook, update-webhook |
| bank_import | /bank-import | index, parse (AJAX), import (AJAX) | | bank_import | /bank-import | index, parse (AJAX), import (AJAX) |
| logs | /logs | index, api (AJAX), clear (AJAX), download, purge (AJAX) | | logs | /logs | index, api (AJAX), clear (AJAX), download, purge (AJAX) |
| utilities | /utilities | index, providers, providers/new, providers/`<id>`, providers/`<id>`/edit, providers/`<id>`/toggle, providers/`<id>`/delete, bills, bills/new, bills/`<id>`/edit, bills/`<id>`/delete, bills/`<id>`/pay, bills/`<id>`/link, bills/`<id>`/unpay, api/usage/`<id>` |
--- ---
+14 -2
View File
@@ -116,6 +116,7 @@ def create_app(config_name=None):
from app.routes.plaid import plaid_bp from app.routes.plaid import plaid_bp
from app.routes.logs import logs_bp from app.routes.logs import logs_bp
from app.routes.bank_import import bank_import_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(health_bp)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
@@ -134,13 +135,14 @@ def create_app(config_name=None):
app.register_blueprint(plaid_bp) app.register_blueprint(plaid_bp)
app.register_blueprint(logs_bp) app.register_blueprint(logs_bp)
app.register_blueprint(bank_import_bp) app.register_blueprint(bank_import_bp)
app.register_blueprint(utilities_bp)
with app.app_context(): with app.app_context():
from app.models import ( from app.models import (
User, Account, Category, Receipt, RecurringRule, User, Account, Category, Receipt, RecurringRule,
Transaction, Budget, Goal, GoalContribution, Transaction, Budget, Goal, GoalContribution,
Investment, InvestmentTransaction, NetWorthSnapshot, Investment, InvestmentTransaction, NetWorthSnapshot,
AiInsight, FxRate AiInsight, FxRate, UtilityProvider, UtilityBill
) )
from app.models.teller_enrollment import TellerEnrollment, TellerAccount from app.models.teller_enrollment import TellerEnrollment, TellerAccount
from app.models.schwab_connection import SchwabConnection, SchwabAccount from app.models.schwab_connection import SchwabConnection, SchwabAccount
@@ -152,7 +154,7 @@ def create_app(config_name=None):
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
from flask_login import current_user as _u 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: if _u.is_authenticated:
try: try:
from app.models.transaction import Transaction as _T from app.models.transaction import Transaction as _T
@@ -162,6 +164,16 @@ def create_app(config_name=None):
).count() ).count()
except Exception: except Exception:
pass 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 return ctx
# ── Session idle timeout ────────────────────────────────────────────────── # ── Session idle timeout ──────────────────────────────────────────────────
+5
View File
@@ -10,5 +10,10 @@ from app.models.investment import Investment, InvestmentTransaction
from app.models.net_worth_snapshot import NetWorthSnapshot from app.models.net_worth_snapshot import NetWorthSnapshot
from app.models.ai_insight import AiInsight from app.models.ai_insight import AiInsight
from app.models.fx_rate import FxRate 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.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
+160
View File
@@ -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', ''),
'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'<UtilityProvider {self.name} ({self.utility_type})>'
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'<UtilityBill provider={self.provider_id} {self.period_start} {self.amount}>'
+533
View File
@@ -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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>')
@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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:provider_id>')
@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)))
+236
View File
@@ -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}'))
+11
View File
@@ -322,6 +322,17 @@
> >
<i class="bi bi-wallet2"></i><span class="lt">Accounts</span> <i class="bi bi-wallet2"></i><span class="lt">Accounts</span>
</a> </a>
<a
href="{{ url_for('utilities.index') }}"
class="sb-link {% if request.blueprint == 'utilities' %}active{% endif %}"
>
<i class="bi bi-lightning-charge"></i
><span class="lt">Utilities
{% if utility_due_count > 0 %}
<span style="margin-left:6px;background:#f59e0b;color:#fff;font-size:10px;font-weight:700;padding:1px 5px;border-radius:10px;line-height:1.4;">{{ utility_due_count }}</span>
{% endif %}
</span>
</a>
<a <a
href="{{ url_for('bank_import.index') }}" href="{{ url_for('bank_import.index') }}"
class="sb-link {% if request.blueprint == 'bank_import' %}active{% endif %}" class="sb-link {% if request.blueprint == 'bank_import' %}active{% endif %}"
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Bills</a>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-9 col-lg-7">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="col-6">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6">
{{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
{{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""), placeholder="0.00", id="amountInput") }}
</div>
{% for e in form.amount.errors %}<div class="text-danger" style="font-size:12px;">{{ e }}</div>{% endfor %}
</div>
<div class="col-6">
{{ form.due_date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.due_date(class="form-control") }}
</div>
</div>
<!-- Usage -->
<div id="usageBlock" style="border-top:1px solid var(--border);padding-top:16px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<label class="form-label fw-medium mb-0" style="font-size:13px;">Consumption <span class="text-muted" style="font-weight:400;">— optional</span></label>
<span class="badge" style="background:#f1f5f9;color:#475569;font-size:11px;" id="unitBadge"></span>
</div>
<div class="row g-3 mb-2">
<div class="col-12 col-sm-4">
{{ form.usage.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.usage(class="form-control", placeholder="0.000", id="usageInput") }}
</div>
<div class="col-6 col-sm-4">
{{ form.meter_start.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.meter_start(class="form-control", placeholder="Previous reading", id="meterStart") }}
</div>
<div class="col-6 col-sm-4">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
</div>
<small class="text-muted" style="font-size:11px;">
Enter usage directly, or both meter readings — readings win and fill in usage automatically.
</small>
<div class="mt-3 p-2" id="rateHint" style="display:none;background:#f8fafc;border-radius:8px;font-size:12px;">
<i class="bi bi-calculator me-1"></i>Unit rate: <span class="mono fw-bold" id="rateValue"></span>
</div>
</div>
<div class="mb-4 mt-3">
{{ 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…") }}
</div>
<div class="d-flex gap-2 flex-wrap">
{{ form.submit(class="btn btn-primary") }}
{% if not bill %}
<button type="submit" name="pay_now" value="yes" class="btn btn-outline-primary">Save &amp; Mark Paid</button>
{% endif %}
<a href="{{ url_for('utilities.bills') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
// provider id -> usage unit, so the form can label and validate consumption
const UNITS = {{ provider_units | tojson }};
const sym = {{ current_user.currency_symbol | tojson }};
const sel = document.getElementById('providerSelect');
const block = document.getElementById('usageBlock');
const badge = document.getElementById('unitBadge');
const usage = document.getElementById('usageInput');
const mStart = document.getElementById('meterStart');
const mEnd = document.getElementById('meterEnd');
const amount = document.getElementById('amountInput');
const hint = document.getElementById('rateHint');
const rateVal = document.getElementById('rateValue');
function unit() { return UNITS[sel.value] || ''; }
function syncUnit() {
const u = unit();
badge.textContent = u || 'no usage tracked';
block.style.opacity = u ? '1' : '.55';
}
function syncUsage() {
const a = parseFloat(mStart.value), b = parseFloat(mEnd.value);
if (!isNaN(a) && !isNaN(b) && b >= a) usage.value = (b - a).toFixed(3);
syncRate();
}
function syncRate() {
const amt = parseFloat(amount.value), u = parseFloat(usage.value);
if (!isNaN(amt) && !isNaN(u) && u > 0) {
rateVal.textContent = sym + (amt / u).toFixed(4) + ' per ' + (unit() || 'unit');
hint.style.display = 'block';
} else {
hint.style.display = 'none';
}
}
sel.addEventListener('change', syncUnit);
[mStart, mEnd].forEach(el => el.addEventListener('input', syncUsage));
[amount, usage].forEach(el => el.addEventListener('input', syncRate));
syncUnit();
syncRate();
})();
</script>
{% endblock %}
+143
View File
@@ -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 %}
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">New Bill</span></a>
{% endblock %}
{% block content %}
<!-- Filters -->
<div class="pcard mb-3">
<form method="GET" class="row g-2 align-items-end">
<div class="col-6 col-md-3">
<label class="form-label fw-medium" style="font-size:12px;">Provider</label>
<select name="provider_id" class="form-select form-select-sm">
<option value="">All providers</option>
{% for p in providers %}
<option value="{{ p.id }}" {% if provider_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label fw-medium" style="font-size:12px;">Type</label>
<select name="utility_type" class="form-select form-select-sm">
<option value="">All types</option>
{% for key, meta in type_meta.items() %}
<option value="{{ key }}" {% if utility_type == key %}selected{% endif %}>{{ meta[0] }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label fw-medium" style="font-size:12px;">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All</option>
<option value="unpaid" {% if status == 'unpaid' %}selected{% endif %}>Unpaid</option>
<option value="overdue" {% if status == 'overdue' %}selected{% endif %}>Overdue</option>
<option value="paid" {% if status == 'paid' %}selected{% endif %}>Paid</option>
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label fw-medium" style="font-size:12px;">Year</label>
<select name="year" class="form-select form-select-sm">
<option value="">All years</option>
{% for y in years %}
<option value="{{ y }}" {% if year == y %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-3 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-funnel me-1"></i>Filter</button>
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Clear</a>
</div>
</form>
</div>
{% if bills %}
<div class="pcard p-0">
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th>Provider</th>
<th>Period</th>
<th class="d-mob-none">Due</th>
<th class="text-end">Amount</th>
<th class="text-end d-mob-none">Usage</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for b in bills %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ b.provider.color }}22;color:{{ b.provider.color }};display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
<i class="bi {{ b.provider.icon }}"></i>
</div>
<div>
<a href="{{ url_for('utilities.provider_detail', id=b.provider_id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ b.provider.name }}</a>
<div style="font-size:11px;color:var(--muted);">{{ b.provider.type_label }}</div>
</div>
</div>
</td>
<td style="font-size:12px;">{{ b.period_label }}</td>
<td class="d-mob-none" style="font-size:12px;">{{ b.due_date.strftime('%b %d, %Y') if b.due_date else '—' }}</td>
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
<td class="text-end mono d-mob-none" style="font-size:12px;">
{% if b.usage %}{{ '%.1f' | format(b.usage | float) }} <span style="color:var(--muted);">{{ b.usage_unit or '' }}</span>{% else %}—{% endif %}
</td>
<td>
{% if b.status == 'paid' %}
<span class="util-chip" style="background:#d1fae5;color:#065f46;">Paid</span>
{% elif b.status == 'overdue' %}
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
{% elif b.status == 'due_soon' %}
<span class="util-chip" style="background:#fef3c7;color:#92400e;">Due in {{ b.days_until_due }}d</span>
{% else %}
<span class="util-chip" style="background:#f1f5f9;color:#475569;">Unpaid</span>
{% endif %}
</td>
<td class="text-end">
{% if not b.is_paid %}
<a href="{{ url_for('utilities.bill_pay', id=b.id) }}" class="btn btn-sm btn-primary" style="font-size:11px;padding:3px 10px;">Pay</a>
{% endif %}
<a href="{{ url_for('utilities.bill_edit', id=b.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:3px 8px;"><i class="bi bi-pencil"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="d-flex justify-content-between align-items-center p-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
<span>Page {{ pagination.page }} of {{ pagination.pages }} &nbsp;·&nbsp; {{ ((pagination.page-1)*30)+1 }}{{ [pagination.page*30, pagination.total]|min }} of {{ pagination.total }}</span>
<div class="d-flex gap-2">
{% if pagination.has_prev %}
<a href="{{ url_for('utilities.bills', page=pagination.prev_num, provider_id=provider_id, utility_type=utility_type, status=status, year=year) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('utilities.bills', page=pagination.next_num, provider_id=provider_id, utility_type=utility_type, status=status, year=year) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-receipt text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No bills match</h5>
<p class="text-muted small mb-3">Try clearing the filters, or record a new bill.</p>
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-primary btn-sm">New Bill</a>
</div>
{% endif %}
{% endblock %}
+280
View File
@@ -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 %}
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
<a href="{{ url_for('utilities.provider_edit', id=provider.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-pencil me-1"></i><span class="btn-label">Edit</span></a>
<a href="{{ url_for('utilities.bill_new', provider_id=provider.id) }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">Add Bill</span></a>
{% endblock %}
{% block content %}
<!-- Header -->
<div class="pcard mb-4" style="border-left:4px solid {{ provider.color }};">
<div class="d-flex align-items-center gap-3 flex-wrap">
<div style="width:46px;height:46px;border-radius:11px;background:{{ provider.color }}22;color:{{ provider.color }};display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">
<i class="bi {{ provider.icon }}"></i>
</div>
<div class="flex-grow-1">
<div style="font-size:16px;font-weight:600;">{{ provider.name }}</div>
<div style="font-size:12px;color:var(--muted);">
{{ provider.type_label }}
{% if provider.account_number %} · Acct <span class="mono">{{ provider.account_number }}</span>{% endif %}
{% if provider.default_account %} · Paid from {{ provider.default_account.name }}{% endif %}
{% if provider.billing_day %} · Bills on day {{ provider.billing_day }}{% endif %}
</div>
</div>
</div>
{% if provider.notes %}
<div style="font-size:12px;color:var(--muted);margin-top:10px;padding-top:10px;border-top:1px solid var(--border);">{{ provider.notes }}</div>
{% endif %}
</div>
<!-- Stats -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">Latest Bill</div>
<div class="stat-value">{{ stats.latest.amount | currency if stats.latest else '—' }}</div>
{% if stats.amount_change_pct is not none %}
<small class="{% if stats.amount_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
<i class="bi bi-arrow-{% if stats.amount_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ stats.amount_change_pct | abs }}% vs prev
</small>
{% endif %}
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">Average Bill</div>
<div class="stat-value">{{ stats.avg_amount | currency }}</div>
<small class="text-muted" style="font-size:11px;">Last 12 months</small>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">12-Month Total</div>
<div class="stat-value">{{ stats.total_12mo | currency }}</div>
<small class="text-muted" style="font-size:11px;">{{ stats.bill_count }} bill{{ '' if stats.bill_count == 1 else 's' }} on record</small>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">{% if provider.tracks_usage %}Avg Usage{% else %}Year over Year{% endif %}</div>
{% if provider.tracks_usage %}
<div class="stat-value">{{ '%.1f' | format(stats.avg_usage) if stats.avg_usage else '—' }}<span style="font-size:13px;color:var(--muted);"> {{ provider.usage_unit }}</span></div>
{% if stats.usage_change_pct is not none %}
<small class="{% if stats.usage_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
<i class="bi bi-arrow-{% if stats.usage_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ stats.usage_change_pct | abs }}% latest vs prev
</small>
{% endif %}
{% else %}
<div class="stat-value">{% if stats.yoy_change_pct is not none %}{{ '+' if stats.yoy_change_pct > 0 else '' }}{{ stats.yoy_change_pct }}%{% else %}—{% endif %}</div>
<small class="text-muted" style="font-size:11px;">Latest vs same period last year</small>
{% endif %}
</div>
</div>
</div>
<!-- Chart -->
{% if series.labels %}
<div class="pcard mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="pcard-title mb-0">Billing History</div>
{% if series.has_usage %}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary active" style="font-size:11px;" data-mode="amount">Amount</button>
<button class="btn btn-outline-secondary" style="font-size:11px;" data-mode="usage">Usage</button>
<button class="btn btn-outline-secondary" style="font-size:11px;" data-mode="rate">Unit Rate</button>
</div>
{% endif %}
</div>
<div style="height:300px;"><canvas id="histChart"></canvas></div>
</div>
{% endif %}
<!-- Bills -->
<div class="pcard p-0">
<div class="d-flex justify-content-between align-items-center p-3" style="border-bottom:1px solid var(--border);">
<div class="pcard-title mb-0">Bills</div>
</div>
{% if bills %}
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th>Period</th>
<th class="d-mob-none">Due</th>
<th class="text-end">Amount</th>
{% if provider.tracks_usage %}
<th class="text-end d-mob-none">Usage</th>
<th class="text-end d-mob-none">Rate</th>
{% endif %}
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for b in bills %}
<tr>
<td style="font-size:12px;">
{{ b.period_label }}
{% if b.notes %}<div style="font-size:11px;color:var(--muted);">{{ b.notes | truncate(60) }}</div>{% endif %}
</td>
<td class="d-mob-none" style="font-size:12px;">{{ b.due_date.strftime('%b %d, %Y') if b.due_date else '—' }}</td>
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
{% if provider.tracks_usage %}
<td class="text-end mono d-mob-none" style="font-size:12px;">
{% if b.usage %}{{ '%.1f' | format(b.usage | float) }} <span style="color:var(--muted);">{{ b.usage_unit or provider.usage_unit }}</span>{% else %}—{% endif %}
</td>
<td class="text-end mono d-mob-none" style="font-size:12px;">
{% if b.rate_per_unit %}{{ current_user.currency_symbol }}{{ '%.4f' | format(b.rate_per_unit) }}{% else %}—{% endif %}
</td>
{% endif %}
<td>
{% if b.status == 'paid' %}
<span class="util-chip" style="background:#d1fae5;color:#065f46;">Paid</span>
{% if b.paid_date %}<div style="font-size:10px;color:var(--muted);margin-top:2px;">{{ b.paid_date.strftime('%b %d') }}</div>{% endif %}
{% elif b.status == 'overdue' %}
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
{% elif b.status == 'due_soon' %}
<span class="util-chip" style="background:#fef3c7;color:#92400e;">Due in {{ b.days_until_due }}d</span>
{% else %}
<span class="util-chip" style="background:#f1f5f9;color:#475569;">Unpaid</span>
{% endif %}
</td>
<td class="text-end">
<div class="dropdown">
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
{% if not b.is_paid %}
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_pay', id=b.id) }}"><i class="bi bi-check2-circle me-2"></i>Mark Paid</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_link', id=b.id) }}"><i class="bi bi-link-45deg me-2"></i>Link Existing Payment</a></li>
{% else %}
{% if b.transaction_id %}
<li><a class="dropdown-item" href="{{ url_for('transactions.edit', id=b.transaction_id) }}"><i class="bi bi-receipt me-2"></i>View Payment</a></li>
{% endif %}
<li>
<form method="POST" action="{{ url_for('utilities.bill_unpay', id=b.id) }}" onsubmit="return confirm('Reopen this bill? A payment transaction created by PFM will be deleted.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item"><i class="bi bi-arrow-counterclockwise me-2"></i>Reopen</button>
</form>
</li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_edit', id=b.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="{{ url_for('utilities.bill_delete', id=b.id) }}" onsubmit="return confirm('Delete this bill?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Delete</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="d-flex justify-content-between align-items-center p-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
<span>Page {{ pagination.page }} of {{ pagination.pages }} &nbsp;·&nbsp; {{ pagination.total }} bills</span>
<div class="d-flex gap-2">
{% if pagination.has_prev %}
<a href="{{ url_for('utilities.provider_detail', id=provider.id, page=pagination.prev_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('utilities.provider_detail', id=provider.id, page=pagination.next_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-receipt text-muted" style="font-size:2.5rem;"></i>
<h6 class="mt-3 mb-1">No bills recorded</h6>
<p class="text-muted small mb-3">Add a bill to start tracking cost{% if provider.tracks_usage %} and usage{% endif %}.</p>
<a href="{{ url_for('utilities.bill_new', provider_id=provider.id) }}" class="btn btn-primary btn-sm">Add First Bill</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{% if series.labels %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
const dark = document.documentElement.getAttribute('data-pfm-dark') === '1';
const grid = dark ? 'rgba(148,163,184,.15)' : 'rgba(148,163,184,.25)';
const tick = dark ? '#94a3b8' : '#64748b';
const sym = {{ current_user.currency_symbol | tojson }};
const color = {{ provider.color | tojson }};
const S = {{ series | tojson }};
const MODES = {
amount: { label: 'Amount', data: S.amounts, type: 'bar', fmt: function (v) { return sym + v.toFixed(2); } },
usage: { label: 'Usage (' + S.unit + ')', data: S.usage, type: 'line', fmt: function (v) { return v.toFixed(1) + ' ' + S.unit; } },
rate: { label: 'Rate per ' + S.unit, data: S.rates, type: 'line', fmt: function (v) { return sym + v.toFixed(4); } }
};
let chart = null;
function render(mode) {
const m = MODES[mode];
if (chart) chart.destroy();
chart = new Chart(document.getElementById('histChart'), {
type: m.type,
data: {
labels: S.labels,
datasets: [{
label: m.label,
data: m.data,
backgroundColor: m.type === 'bar' ? color : color + '22',
borderColor: color,
borderWidth: 2,
borderRadius: 4,
tension: .3,
fill: m.type === 'line',
pointRadius: 3,
spanGaps: true
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: function (c) { return m.label + ': ' + m.fmt(c.parsed.y); } } }
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 11 }, color: tick } },
y: { grid: { color: grid }, ticks: { font: { size: 11 }, color: tick,
callback: function (v) { return mode === 'amount' ? sym + v : v; } } }
}
}
});
}
render('amount');
document.querySelectorAll('[data-mode]').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('[data-mode]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
render(btn.dataset.mode);
});
});
})();
</script>
{% endif %}
{% endblock %}
+297
View File
@@ -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 %}
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-list-ul me-1"></i><span class="btn-label">All Bills</span></a>
<a href="{{ url_for('utilities.providers') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-building me-1"></i><span class="btn-label">Providers</span></a>
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">New Bill</span></a>
{% endblock %}
{% block content %}
<!-- Stat cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">This Month</div>
<div class="stat-value text-expense">{{ summary.this_month | currency }}</div>
{% if summary.month_change_pct is not none %}
<small class="{% if summary.month_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
<i class="bi bi-arrow-{% if summary.month_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ summary.month_change_pct | abs }}% vs last month
</small>
{% endif %}
</div>
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-lightning-charge"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Monthly Average</div>
<div class="stat-value">{{ summary.avg_monthly | currency }}</div>
<small class="text-muted" style="font-size:11px;">Last 12 months</small>
</div>
<div class="stat-icon" style="background:#e0f2fe;color:#075985;"><i class="bi bi-bar-chart"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Year to Date</div>
<div class="stat-value">{{ summary.ytd | currency }}</div>
</div>
<div class="stat-icon" style="background:#fef3c7;color:#92400e;"><i class="bi bi-calendar3"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card" style="{% if summary.overdue_count %}border-color:#fecaca;{% endif %}">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Unpaid</div>
<div class="stat-value {% if summary.overdue_count %}text-expense{% endif %}">{{ summary.unpaid_total | currency }}</div>
<small class="text-muted" style="font-size:11px;">
{{ summary.unpaid_count }} bill{{ '' if summary.unpaid_count == 1 else 's' }}{% if summary.overdue_count %} · <span class="text-expense fw-bold">{{ summary.overdue_count }} overdue</span>{% endif %}
</small>
</div>
<div class="stat-icon" style="background:{% if summary.overdue_count %}#fee2e2;color:#991b1b{% else %}#f1f5f9;color:#475569{% endif %};"><i class="bi bi-receipt"></i></div>
</div>
</div>
</div>
</div>
<!-- Upcoming due -->
{% if summary.upcoming %}
<div class="pcard mb-4" style="border-left:4px solid {% if summary.overdue_count %}#ef4444{% else %}#f59e0b{% endif %};">
<div class="pcard-title mb-3">Bills Due</div>
<div class="table-wrap">
<table class="pfm-table">
<thead><tr><th>Provider</th><th>Period</th><th class="d-mob-none">Due</th><th class="text-end">Amount</th><th class="text-end">Action</th></tr></thead>
<tbody>
{% for b in summary.upcoming %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ b.provider.color }}22;color:{{ b.provider.color }};display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
<i class="bi {{ b.provider.icon }}"></i>
</div>
<a href="{{ url_for('utilities.provider_detail', id=b.provider_id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ b.provider.name }}</a>
</div>
</td>
<td style="font-size:12px;color:var(--muted);">{{ b.period_label }}</td>
<td class="d-mob-none" style="font-size:12px;">
{{ b.due_date.strftime('%b %d') }}
{% if b.status == 'overdue' %}
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
{% elif b.status == 'due_soon' %}
<span class="util-chip" style="background:#fef3c7;color:#92400e;">in {{ b.days_until_due }}d</span>
{% endif %}
</td>
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
<td class="text-end">
<a href="{{ url_for('utilities.bill_pay', id=b.id) }}" class="btn btn-sm btn-primary" style="font-size:11px;padding:3px 10px;">Pay</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="row g-3 mb-4">
<!-- Monthly spend chart -->
<div class="col-12 col-xl-8">
<div class="pcard h-100">
<div class="pcard-title mb-3">Utility Spend — Last 12 Months</div>
{% if chart.datasets %}
<div style="height:280px;"><canvas id="monthlyChart"></canvas></div>
{% else %}
<div class="text-center text-muted py-5" style="font-size:13px;">No bills recorded yet.</div>
{% endif %}
</div>
</div>
<!-- By type -->
<div class="col-12 col-xl-4">
<div class="pcard h-100">
<div class="pcard-title mb-3">By Type — 12 Months</div>
{% if type_totals %}
{% set grand = type_totals | sum(attribute='total') %}
{% for t in type_totals %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span style="font-size:12px;font-weight:600;"><i class="bi {{ t.icon }} me-1" style="color:{{ t.color }};"></i>{{ t.label }}</span>
<span class="mono" style="font-size:12px;">{{ t.total | currency }}</span>
</div>
<div style="height:6px;background:#f1f5f9;border-radius:3px;">
<div style="height:6px;border-radius:3px;background:{{ t.color }};width:{{ (t.total / grand * 100) if grand else 0 }}%;"></div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-4" style="font-size:13px;">No data yet.</div>
{% endif %}
</div>
</div>
</div>
<!-- Providers -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="pcard-title mb-0">Providers</div>
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;"><i class="bi bi-plus-lg me-1"></i>Add Provider</a>
</div>
{% if providers %}
<div class="row g-3">
{% for p in providers %}
{% set s = summaries[p.id] %}
<div class="col-12 col-md-6 col-xl-4">
<div class="util-card" style="border-left:4px solid {{ p.color }};">
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="d-flex align-items-center gap-2">
<div style="width:36px;height:36px;border-radius:9px;background:{{ p.color }}22;color:{{ p.color }};display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;">
<i class="bi {{ p.icon }}"></i>
</div>
<div>
<div style="font-size:14px;font-weight:600;">{{ p.name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ p.type_label }}{% if s.unpaid_count %} · <span class="text-expense fw-bold">{{ s.unpaid_count }} unpaid</span>{% endif %}</div>
</div>
</div>
<div class="dropdown">
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_new', provider_id=p.id) }}"><i class="bi bi-plus-circle me-2"></i>Add Bill</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_detail', id=p.id) }}"><i class="bi bi-graph-up me-2"></i>History &amp; Usage</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_edit', id=p.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
</ul>
</div>
</div>
{% if s.latest %}
<div class="d-flex justify-content-between align-items-end">
<div>
<div style="font-size:11px;color:var(--muted);">Latest · {{ s.latest.period_start.strftime('%b %Y') }}</div>
<div class="mono fw-bold" style="font-size:18px;">{{ s.latest.amount | currency }}</div>
</div>
<div class="text-end">
{% if s.amount_change_pct is not none %}
<div class="{% if s.amount_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:12px;font-weight:600;">
<i class="bi bi-arrow-{% if s.amount_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ s.amount_change_pct | abs }}%
</div>
<div style="font-size:10px;color:var(--muted);">vs prev period</div>
{% endif %}
</div>
</div>
{% if s.latest.usage %}
<div class="d-flex justify-content-between mt-2 pt-2" style="border-top:1px solid var(--border);font-size:12px;">
<span class="text-muted">Usage</span>
<span class="mono">{{ '%.1f' | format(s.latest.usage | float) }} {{ s.latest.usage_unit or p.usage_unit }}
{% if s.usage_change_pct is not none %}
<span class="{% if s.usage_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">({{ '+' if s.usage_change_pct > 0 else '' }}{{ s.usage_change_pct }}%)</span>
{% endif %}
</span>
</div>
{% endif %}
{% if s.latest.rate_per_unit %}
<div class="d-flex justify-content-between mt-1" style="font-size:12px;">
<span class="text-muted">Unit rate</span>
<span class="mono">{{ current_user.currency_symbol }}{{ '%.4f' | format(s.latest.rate_per_unit) }} / {{ s.latest.usage_unit or p.usage_unit }}</span>
</div>
{% endif %}
<div class="d-flex justify-content-between mt-1" style="font-size:12px;">
<span class="text-muted">12-month total</span>
<span class="mono">{{ s.total_12mo | currency }}</span>
</div>
{% if s.latest.status != 'paid' %}
<a href="{{ url_for('utilities.bill_pay', id=s.latest.id) }}" class="btn btn-sm w-100 mt-3" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
<i class="bi bi-check2-circle me-1"></i>Mark Latest Paid
</a>
{% else %}
<a href="{{ url_for('utilities.bill_new', provider_id=p.id) }}" class="btn btn-sm w-100 mt-3" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
<i class="bi bi-plus-lg me-1"></i>Add Bill
</a>
{% endif %}
{% else %}
<div class="text-center text-muted py-3" style="font-size:12px;">No bills yet</div>
<a href="{{ url_for('utilities.bill_new', provider_id=p.id) }}" class="btn btn-sm w-100" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
<i class="bi bi-plus-lg me-1"></i>Add First Bill
</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-lightning-charge text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No utility providers yet</h5>
<p class="text-muted small mb-3">Add your electricity, water, gas, and internet providers to track bills and usage.</p>
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-primary btn-sm">Add First Provider</a>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if chart.datasets %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
const dark = document.documentElement.getAttribute('data-pfm-dark') === '1';
const grid = dark ? 'rgba(148,163,184,.15)' : 'rgba(148,163,184,.25)';
const tick = dark ? '#94a3b8' : '#64748b';
const sym = {{ current_user.currency_symbol | tojson }};
new Chart(document.getElementById('monthlyChart'), {
type: 'bar',
data: {
labels: {{ chart.labels | tojson }},
datasets: {{ chart.datasets | tojson }}.map(function (d) {
return { label: d.label, data: d.data, backgroundColor: d.color, borderRadius: 4, borderSkipped: false };
})
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, font: { size: 11 }, color: tick } },
tooltip: {
callbacks: {
label: function (c) { return c.dataset.label + ': ' + sym + c.parsed.y.toFixed(2); },
footer: function (items) {
const total = items.reduce(function (s, i) { return s + i.parsed.y; }, 0);
return 'Total: ' + sym + total.toFixed(2);
}
}
}
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 11 }, color: tick } },
y: { stacked: true, grid: { color: grid }, ticks: { font: { size: 11 }, color: tick, callback: function (v) { return sym + v; } } }
}
}
});
})();
</script>
{% endif %}
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Link Payment{% endblock %}
{% block page_title %}Link Existing Payment{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← {{ bill.provider.name }}</a>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-lg-9">
<!-- Bill summary -->
<div class="pcard mb-3" style="border-left:4px solid {{ bill.provider.color }};">
<div class="d-flex align-items-center gap-3">
<div style="width:42px;height:42px;border-radius:10px;background:{{ bill.provider.color }}22;color:{{ bill.provider.color }};display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;">
<i class="bi {{ bill.provider.icon }}"></i>
</div>
<div class="flex-grow-1">
<div style="font-size:14px;font-weight:600;">{{ bill.provider.name }}</div>
<div style="font-size:12px;color:var(--muted);">{{ bill.period_label }}{% if bill.due_date %} · due {{ bill.due_date.strftime('%b %d, %Y') }}{% endif %}</div>
</div>
<div class="mono fw-bold" style="font-size:20px;">{{ bill.amount | currency }}</div>
</div>
</div>
<div class="pcard p-0">
<div class="p-3" style="border-bottom:1px solid var(--border);">
<div class="pcard-title mb-1">Candidate Transactions</div>
<div class="text-muted" style="font-size:12px;">
Expenses within 45 days of the due date, closest amount first. Transactions already linked to another bill are hidden.
</div>
</div>
{% if candidates %}
<form method="POST" id="linkForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th style="width:40px;"></th>
<th>Date</th>
<th>Description</th>
<th class="d-mob-none">Account</th>
<th class="d-mob-none">Category</th>
<th class="text-end">Amount</th>
<th class="text-end d-mob-none">Δ</th>
</tr>
</thead>
<tbody>
{% for t in candidates %}
{% set diff = (t.amount | float) - (bill.amount | float) %}
<tr style="cursor:pointer;" onclick="this.querySelector('input[type=radio]').checked = true;">
<td><input type="radio" name="transaction_id" value="{{ t.id }}" class="form-check-input" {% if loop.first %}checked{% endif %}></td>
<td style="font-size:12px;">{{ t.date.strftime('%b %d, %Y') }}</td>
<td style="font-size:13px;">{{ t.description | truncate(48) }}</td>
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ t.account.name if t.account else '—' }}</td>
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ t.category.name if t.category else '—' }}</td>
<td class="text-end mono fw-bold" style="font-size:13px;">{{ t.amount | currency }}</td>
<td class="text-end mono d-mob-none" style="font-size:12px;color:{% if diff == 0 %}#10b981{% else %}var(--muted){% endif %};">
{% if diff == 0 %}exact{% else %}{{ '+' if diff > 0 else '' }}{{ '%.2f' | format(diff) }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex gap-2 p-3" style="border-top:1px solid var(--border);">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-link-45deg me-1"></i>Link Selected Payment</button>
<a href="{{ url_for('utilities.bill_pay', id=bill.id) }}" class="btn btn-outline-primary btn-sm">Create New Transaction Instead</a>
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
</form>
{% else %}
<div class="text-center py-5">
<i class="bi bi-search text-muted" style="font-size:2.5rem;"></i>
<h6 class="mt-3 mb-1">No matching transactions</h6>
<p class="text-muted small mb-3">Nothing unlinked was found near this bill's due date.</p>
<a href="{{ url_for('utilities.bill_pay', id=bill.id) }}" class="btn btn-primary btn-sm">Create a Payment Transaction</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Pay Bill{% endblock %}
{% block page_title %}Mark Bill Paid{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← {{ bill.provider.name }}</a>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-5">
<!-- Bill summary -->
<div class="pcard mb-3" style="border-left:4px solid {{ bill.provider.color }};">
<div class="d-flex align-items-center gap-3">
<div style="width:42px;height:42px;border-radius:10px;background:{{ bill.provider.color }}22;color:{{ bill.provider.color }};display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;">
<i class="bi {{ bill.provider.icon }}"></i>
</div>
<div class="flex-grow-1">
<div style="font-size:14px;font-weight:600;">{{ bill.provider.name }}</div>
<div style="font-size:12px;color:var(--muted);">{{ bill.period_label }}</div>
</div>
<div class="text-end">
<div class="mono fw-bold" style="font-size:20px;">{{ bill.amount | currency }}</div>
{% if bill.due_date %}
<div style="font-size:11px;color:var(--muted);">Due {{ bill.due_date.strftime('%b %d, %Y') }}</div>
{% endif %}
</div>
</div>
{% if bill.usage %}
<div class="d-flex justify-content-between mt-3 pt-3" style="border-top:1px solid var(--border);font-size:12px;">
<span class="text-muted">Usage</span>
<span class="mono">{{ '%.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 %}
</span>
</div>
{% endif %}
</div>
<div class="pcard">
<p class="text-muted" style="font-size:12px;">
This records an expense transaction for the bill amount and links the two together.
If the payment already came in through a bank sync,
<a href="{{ url_for('utilities.bill_link', id=bill.id) }}">link the existing transaction</a> instead to avoid a duplicate.
</p>
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="row g-3 mb-4">
<div class="col-7">
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_id(class="form-select") }}
</div>
<div class="col-5">
{{ form.paid_date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.paid_date(class="form-control") }}
</div>
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.providers') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Providers</a>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3 mb-3">
<div class="col-12 col-sm-7">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="col-12 col-sm-5">
{{ form.utility_type.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.utility_type(class="form-select", id="typeSelect") }}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-sm-7">
{{ form.account_number.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.account_number(class="form-control", placeholder="Customer / meter account no.") }}
</div>
<div class="col-6 col-sm-5">
{{ 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") }}
<small class="text-muted" style="font-size:11px;">Day the bill usually arrives</small>
</div>
</div>
<div class="mb-3">
{{ 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") }}
<small class="text-muted" style="font-size:11px;">Bills for this provider will record consumption in this unit.</small>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-sm-6">
{{ form.default_account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.default_account_id(class="form-select") }}
</div>
<div class="col-12 col-sm-6">
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_id(class="form-select") }}
</div>
</div>
<div class="mb-3">
{{ 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…") }}
</div>
<!-- Color -->
<div class="mb-3">
<label class="form-label fw-medium" style="font-size:13px;">Color</label>
<div class="d-flex gap-2 flex-wrap">
{% for c in colors %}
<label style="cursor:pointer;">
<input type="radio" name="color" value="{{ c }}" style="display:none;" {% if (provider and provider.color==c) or (not provider and loop.first) %}checked{% endif %}>
<div style="width:26px;height:26px;border-radius:50%;background:{{ c }};border:3px solid transparent;" class="color-swatch" data-color="{{ c }}"></div>
</label>
{% endfor %}
</div>
{{ form.color(type="hidden", id="colorInput") }}
</div>
<!-- Icon -->
<div class="mb-4">
<label class="form-label fw-medium" style="font-size:13px;">Icon</label>
<div class="d-flex gap-2 flex-wrap">
{% for icon_val in icons %}
<label style="cursor:pointer;">
<input type="radio" name="icon" value="{{ icon_val }}" style="display:none;" {% if provider and provider.icon==icon_val %}checked{% elif not provider and loop.first %}checked{% endif %}>
<div style="width:34px;height:34px;border-radius:8px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:16px;border:2px solid transparent;" class="icon-swatch">
<i class="bi {{ icon_val }}"></i>
</div>
</label>
{% endfor %}
</div>
{{ form.icon(type="hidden", id="iconInput") }}
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('utilities.providers') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Suggest a usage unit + color/icon when the utility type changes (new providers only)
const TYPE_DEFAULTS = {{ type_defaults | tojson }};
const isNew = {{ 'false' if provider else 'true' }};
document.getElementById('typeSelect').addEventListener('change', function () {
const d = TYPE_DEFAULTS[this.value];
if (!d) return;
const unit = document.getElementById('unitInput');
if (isNew || !unit.value) unit.value = d.unit;
if (isNew) {
document.getElementById('colorInput').value = d.color;
document.getElementById('iconInput').value = d.icon;
document.querySelectorAll('.color-swatch').forEach(function (s) {
const on = s.dataset.color.toLowerCase() === d.color.toLowerCase();
s.style.borderColor = on ? '#fff' : 'transparent';
s.style.outline = on ? '2px solid ' + d.color : 'none';
});
document.querySelectorAll('.icon-swatch').forEach(function (s) {
const on = s.querySelector('i').className.indexOf(d.icon) !== -1;
s.style.background = on ? '#dbeafe' : '#f1f5f9';
s.style.borderColor = on ? '#3b82f6' : 'transparent';
});
}
});
document.querySelectorAll('.color-swatch').forEach(function (sw) {
const inp = sw.closest('label').querySelector('input');
inp.addEventListener('change', function () {
document.getElementById('colorInput').value = this.value;
document.querySelectorAll('.color-swatch').forEach(s => { s.style.borderColor='transparent'; s.style.outline='none'; });
sw.style.borderColor='#fff'; sw.style.outline='2px solid '+this.value;
});
if (inp.checked) { sw.style.borderColor='#fff'; sw.style.outline='2px solid '+sw.dataset.color; document.getElementById('colorInput').value=sw.dataset.color; }
});
document.querySelectorAll('.icon-swatch').forEach(function (sw) {
const inp = sw.closest('label').querySelector('input');
inp.addEventListener('change', function () {
document.getElementById('iconInput').value = this.value;
document.querySelectorAll('.icon-swatch').forEach(s => { s.style.background='#f1f5f9'; s.style.borderColor='transparent'; });
sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6';
});
if (inp.checked) { sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6'; document.getElementById('iconInput').value=inp.value; }
});
</script>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Utility Providers{% endblock %}
{% block page_title %}Utility Providers{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">Add Provider</span></a>
{% endblock %}
{% block content %}
{% if providers %}
<div class="pcard p-0">
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th>Provider</th>
<th>Type</th>
<th class="d-mob-none">Account No.</th>
<th class="d-mob-none">Unit</th>
<th class="d-mob-none">Pays From</th>
<th class="text-end">Bills</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for p in providers %}
<tr {% if not p.is_active %}style="opacity:.55;"{% endif %}>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:30px;height:30px;border-radius:8px;background:{{ p.color }}22;color:{{ p.color }};display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0;">
<i class="bi {{ p.icon }}"></i>
</div>
<div>
<a href="{{ url_for('utilities.provider_detail', id=p.id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ p.name }}</a>
{% if not p.is_active %}<span class="badge bg-secondary" style="font-size:9px;">Archived</span>{% endif %}
{% if p.billing_day %}<div style="font-size:11px;color:var(--muted);">Bills on day {{ p.billing_day }}</div>{% endif %}
</div>
</div>
</td>
<td style="font-size:12px;">{{ p.type_label }}</td>
<td class="d-mob-none mono" style="font-size:12px;color:var(--muted);">{{ p.account_number or '—' }}</td>
<td class="d-mob-none" style="font-size:12px;">{{ p.usage_unit or '—' }}</td>
<td class="d-mob-none" style="font-size:12px;">{{ p.default_account.name if p.default_account else '—' }}</td>
<td class="text-end mono" style="font-size:12px;">{{ counts[p.id] }}</td>
<td class="text-end">
<div class="dropdown">
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_detail', id=p.id) }}"><i class="bi bi-graph-up me-2"></i>History &amp; Usage</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_new', provider_id=p.id) }}"><i class="bi bi-plus-circle me-2"></i>Add Bill</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_edit', id=p.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="{{ url_for('utilities.provider_toggle', id=p.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-{{ 'arrow-counterclockwise' if not p.is_active else 'archive' }} me-2"></i>{{ 'Reactivate' if not p.is_active else 'Archive' }}
</button>
</form>
</li>
<li>
<form method="POST" action="{{ url_for('utilities.provider_delete', id=p.id) }}"
onsubmit="return confirm('Delete {{ p.name }}{% if counts[p.id] %} and its {{ counts[p.id] }} bill(s){% endif %}? This cannot be undone.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="confirm_bills" value="yes">
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Delete</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted mt-3" style="font-size:12px;">
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.
</p>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-building text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No providers yet</h5>
<p class="text-muted small mb-3">Add electricity, water, gas, and internet providers to start tracking.</p>
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-primary btn-sm">Add First Provider</a>
</div>
{% endif %}
{% endblock %}
+83
View File
@@ -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.')