Aug 17 - Update with utilities management
This commit is contained in:
@@ -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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
from app.extensions import db
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
|
||||||
|
# type key -> (label, icon, color, default usage unit)
|
||||||
|
UTILITY_TYPE_META = {
|
||||||
|
'electricity': ('Electricity', 'bi-lightning-charge', '#f59e0b', 'kWh'),
|
||||||
|
'water': ('Water', 'bi-droplet', '#0ea5e9', 'm³'),
|
||||||
|
'gas': ('Gas', 'bi-fire', '#ef4444', 'therms'),
|
||||||
|
'internet': ('Internet', 'bi-wifi', '#6366f1', 'GB'),
|
||||||
|
'phone': ('Phone', 'bi-phone', '#8b5cf6', 'GB'),
|
||||||
|
'trash': ('Trash', 'bi-trash3', '#64748b', ''),
|
||||||
|
'other': ('Other', 'bi-plug', '#94a3b8', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
UTILITY_TYPES = list(UTILITY_TYPE_META.keys())
|
||||||
|
|
||||||
|
# A bill is flagged "due soon" this many days before its due date
|
||||||
|
DUE_SOON_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
class UtilityProvider(db.Model):
|
||||||
|
"""A utility company / service you receive bills from (PG&E, Comcast, ...)."""
|
||||||
|
|
||||||
|
__tablename__ = 'utility_providers'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
utility_type = db.Column(
|
||||||
|
db.Enum(*UTILITY_TYPES),
|
||||||
|
nullable=False,
|
||||||
|
default='electricity'
|
||||||
|
)
|
||||||
|
account_number = db.Column(db.String(100), nullable=True) # customer/meter account no.
|
||||||
|
usage_unit = db.Column(db.String(20), nullable=True) # kWh, m³, GB — blank = no usage tracking
|
||||||
|
|
||||||
|
# Where bills get paid from, and what expense category they land in
|
||||||
|
default_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True)
|
||||||
|
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
|
||||||
|
|
||||||
|
billing_day = db.Column(db.Integer, nullable=True) # day of month the bill arrives
|
||||||
|
color = db.Column(db.String(7), default='#8b5cf6')
|
||||||
|
icon = db.Column(db.String(50), default='bi-lightning-charge')
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
default_account = db.relationship('Account')
|
||||||
|
category = db.relationship('Category')
|
||||||
|
bills = db.relationship('UtilityBill', back_populates='provider',
|
||||||
|
lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type_label(self):
|
||||||
|
return UTILITY_TYPE_META.get(self.utility_type, UTILITY_TYPE_META['other'])[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tracks_usage(self):
|
||||||
|
return bool(self.usage_unit)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<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}>'
|
||||||
@@ -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)))
|
||||||
@@ -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}'))
|
||||||
@@ -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 %}"
|
||||||
|
|||||||
@@ -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 & 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 %}
|
||||||
@@ -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 }} · {{ ((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 %}
|
||||||
@@ -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 }} · {{ 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 %}
|
||||||
@@ -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 & 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 & 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 %}
|
||||||
@@ -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.')
|
||||||
Reference in New Issue
Block a user