Compare commits
11
Commits
2f62417c9f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceb1d1395d | ||
|
|
790eb9894e | ||
|
|
5db79313a9 | ||
|
|
231a9d2193 | ||
|
|
e4007348f8 | ||
|
|
025f3f8823 | ||
|
|
458044201e | ||
|
|
9c9aa694c4 | ||
|
|
db44d6057b | ||
|
|
1a4e68b422 | ||
|
|
04acefd9f8 |
@@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,26 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
|
||||
- **Purge** dropdown (7 / 30 / 90 days) via AJAX `POST /logs/purge` — deletes `app_logs` rows older than N days
|
||||
- DB entry count shown in header chip
|
||||
|
||||
### 2.17 Plaid Bank Sync
|
||||
### 2.17 Financial Health Score
|
||||
- Dashboard widget: 0–100 score with letter grade (A/B/C/D/F) + color
|
||||
- Computed by `app/services/health_score_service.py::compute_health_score()`, served via `GET /api/health-score` (AJAX, dashboard.py)
|
||||
- 4 components, 25 points each:
|
||||
- **Savings Rate** — 3-month avg; ≥20% full marks, 10–20% → 18pts, 1–10% → 10pts, ≤0% → 0
|
||||
- **Budget Adherence** — fraction of budgeted categories currently under limit (no budgets set = not penalised)
|
||||
- **Goal Progress** — avg completion % across active (non-completed) goals
|
||||
- **Emergency Fund** — liquid assets vs 3-month expense target, scales linearly
|
||||
- Each component returns a `tip` string when below target; surfaced in UI
|
||||
|
||||
### 2.18 Budget Alerts (Email)
|
||||
- Threshold alerts at 80% and 100% of a category's monthly budget (limit + rollover)
|
||||
- Triggered by `app/services/alert_service.py::check_and_flash_budget_alerts()`, called after committing an expense transaction
|
||||
- **Dedup**: `budgets.alert_sent_80` / `alert_sent_100` boolean flags prevent repeat sends within the same month
|
||||
- **In-app**: Flask `flash()` message (warning/danger) always shown when a threshold is newly crossed
|
||||
- **Email**: HTML email sent only if `budget_alerts_enabled` is on (Settings → Profile) AND SMTP is configured (`SMTP_HOST`, `SMTP_USER`, `SMTP_PASSWORD`, `ALERT_EMAIL` all set)
|
||||
- Settings → Profile: "Email me when a budget category reaches 80% or 100%" checkbox (`users.budget_alerts_enabled`) + "Send test email" button (`POST /settings/test-email`) — shows SMTP-configured status inline
|
||||
- Uses stdlib `smtplib` + `ssl` (STARTTLS), no third-party mail service
|
||||
|
||||
### 2.19 Plaid Bank Sync
|
||||
- Connects 12,000+ US financial institutions via Plaid API
|
||||
- **Plaid page** (`/plaid/`) — accessible via Settings; connect/disconnect/sync/billing
|
||||
- **Link flow**: AJAX `POST /plaid/create-link-token` → open Plaid Link widget (CDN JS) → `onSuccess(public_token)` → AJAX `POST /plaid/exchange-token` → redirect to account mapping
|
||||
@@ -234,11 +253,35 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
|
||||
- **Update webhook for existing items** (`POST /plaid/update-webhook`) — calls Plaid `/item/webhook/update` for all active items; "Apply to Existing Items" button shown on Plaid page when URL is configured
|
||||
- **Not in sidebar** — accessed via Settings page only
|
||||
|
||||
### 2.20 Utilities (Electricity / Water / Gas / Internet)
|
||||
- Sidebar link: "Utilities" under Money section; amber badge counts bills due within 7 days
|
||||
- **Providers** (`/utilities/providers`) — one record per utility company
|
||||
- Types: electricity, water, gas, internet, phone, trash, other (each with a default icon/color/usage unit)
|
||||
- Fields: name, type, account number, usage unit, pay-from account, expense category, billing day, color, icon, notes
|
||||
- `usage_unit` blank = no consumption tracking for that provider (the bill form dims those fields)
|
||||
- Changing the type on a **new** provider auto-fills unit/icon/color; editing an existing one never overwrites choices
|
||||
- Archive (hides from dashboard, keeps history) or delete (cascades to bills; payment transactions are left in place)
|
||||
- **Bills** (`/utilities/bills`) — one record per billing period
|
||||
- Fields: period start/end, amount, due date, usage, meter start/end, notes
|
||||
- Usage entered directly **or** derived from meter readings — readings win (`sync_usage_from_meter`)
|
||||
- Live unit-rate hint on the form; `usage_unit` snapshotted from the provider at entry time
|
||||
- `UNIQUE (provider_id, period_start)` — duplicate periods rejected with a flash, not a 500
|
||||
- Validation: period end ≥ period start; meter end ≥ meter start
|
||||
- Filters: provider, type, status (unpaid / overdue / paid), year; pagination 30/page
|
||||
- **Status** is derived, not stored: `paid` → `overdue` (past due) → `due_soon` (≤ 7 days) → `unpaid`
|
||||
- **Payment — two paths** (both set `utility_bills.transaction_id`):
|
||||
- **Mark Paid** (`/utilities/bills/<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)
|
||||
|
||||
### All 23 Tables
|
||||
### All 25 Tables
|
||||
```
|
||||
users — single user, hashed password, currency/timezone prefs, totp_secret, totp_enabled
|
||||
accounts — bank/wallet accounts (balance managed per provider rules)
|
||||
@@ -263,6 +306,8 @@ schwab_accounts — Schwab account ↔ PFM account mapping, account_hash
|
||||
plaid_items — Plaid item: item_id, access_token (EncryptedText), institution_name, cursor, last_synced_at
|
||||
plaid_accounts — Plaid account ↔ PFM account mapping; cc_due_date, cc_minimum_payment, cc_last_statement_balance, cc_is_overdue
|
||||
plaid_sync_previews — temporary preview data: item_id (UNIQUE), data_json (TEXT), next_cursor
|
||||
utility_providers — utility company: name, utility_type, usage_unit, default_account_id, category_id, billing_day
|
||||
utility_bills — one billing period: amount, period, due_date, is_paid, transaction_id, usage_amount, meter readings
|
||||
```
|
||||
|
||||
### Key Column Notes
|
||||
@@ -277,6 +322,11 @@ plaid_sync_previews — temporary preview data: item_id (UNIQUE), data_json
|
||||
- `plaid_accounts.cc_*` — credit card billing fields updated by `POST /plaid/liabilities/<item_db_id>`
|
||||
- `app_logs.message` — raw `record.getMessage()` + exception traceback (if any); no pipe-delimited prefix
|
||||
- `users.totp_secret` — base32 TOTP secret (VARCHAR 64); NULL when 2FA disabled
|
||||
- `users.budget_alerts_enabled` — boolean; toggles budget threshold emails (Settings → Profile)
|
||||
- `budgets.alert_sent_80` / `alert_sent_100` — dedup flags so threshold emails/flashes fire once per month per category
|
||||
- `utility_bills.usage_amount` — mapped to the Python attribute `UtilityBill.usage`; the column is NOT named `usage` because that is a reserved word in MySQL
|
||||
- `utility_bills.transaction_id` — nullable FK to `transactions.id`; set by both mark-paid and link-existing. A transaction whose notes start with `Utility:<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
|
||||
```
|
||||
@@ -284,8 +334,13 @@ scripts/add_investment_account.py — adds investments.account_id column (run
|
||||
scripts/add_security_columns.py — adds totp columns, audit_logs table, widens token columns to TEXT (run once)
|
||||
scripts/add_plaid_tables.py — creates plaid_items, plaid_accounts, plaid_sync_previews tables (run once)
|
||||
scripts/add_log_tables.py — creates audit_logs and app_logs tables (run once; safe to re-run)
|
||||
scripts/add_utility_tables.py — creates utility_providers and utility_bills tables (run once; safe to re-run)
|
||||
```
|
||||
|
||||
Prefer `flask db migrate` + `flask db upgrade` where the Alembic chain is healthy — these scripts are the
|
||||
fallback for schema managed outside the chain. Always read a generated migration before running it:
|
||||
autogenerate cannot see models missing from `app/models/__init__.py` and will propose dropping their tables.
|
||||
|
||||
---
|
||||
|
||||
## 4. Project File Structure (Actual)
|
||||
@@ -318,6 +373,7 @@ pfm/ # /home/pfm/web on server
|
||||
│ │ ├── net_worth_snapshot.py
|
||||
│ │ ├── ai_insight.py
|
||||
│ │ ├── fx_rate.py
|
||||
│ │ ├── utility.py # UtilityProvider, UtilityBill (+ UTILITY_TYPE_META)
|
||||
│ │ ├── audit_log.py # AuditLog model (action, description, ip_address, timestamp)
|
||||
│ │ ├── app_log.py # AppLog model (timestamp, level, module, message TEXT)
|
||||
│ │ ├── teller_enrollment.py # access_token now EncryptedText (TEXT column)
|
||||
@@ -326,7 +382,7 @@ pfm/ # /home/pfm/web on server
|
||||
│ │
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.py # + TOTP verify/setup/disable routes; rate limits; audit calls
|
||||
│ │ ├── dashboard.py # + savings_rate; Schwab expiry warning; reconcile API; checking/savings/investment totals
|
||||
│ │ ├── dashboard.py # + savings_rate; Schwab expiry warning; reconcile API; checking/savings/investment totals; health-score API
|
||||
│ │ ├── accounts.py # teller_map + schwab_map + plaid_map; skip calc_balance for providers
|
||||
│ │ ├── categories.py
|
||||
│ │ ├── transactions.py # + set-category AJAX; bulk-action AJAX; quick date filter vars; fixed income-form-submits-as-expense bug
|
||||
@@ -335,28 +391,32 @@ pfm/ # /home/pfm/web on server
|
||||
│ │ ├── investments.py # + sync-schwab route; price-history API
|
||||
│ │ ├── reports.py
|
||||
│ │ ├── ai.py
|
||||
│ │ ├── settings.py # + audit_log route; audit_purge route; audit calls on password change
|
||||
│ │ ├── settings.py # + audit_log route; audit_purge route; audit calls on password change; MIME magic-byte check on receipt upload; test-email route
|
||||
│ │ ├── teller.py # balance uses ledger/available correctly
|
||||
│ │ ├── schwab.py # OAuth, mapping, sync, snapshot; audit calls; fallback type 'other'
|
||||
│ │ ├── plaid.py # Link flow, exchange, map, sync preview/confirm, balance, liabilities, resync, disconnect
|
||||
│ │ ├── bank_import.py
|
||||
│ │ └── logs.py # DB-backed API; purge endpoint; clear truncates DB + file
|
||||
│ │ ├── logs.py # DB-backed API; purge endpoint; clear truncates DB + file
|
||||
│ │ └── utilities.py # providers + bills CRUD, mark-paid, link payment, usage API
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── account_service.py
|
||||
│ │ ├── ai_service.py
|
||||
│ │ ├── budget_service.py
|
||||
│ │ ├── export_service.py
|
||||
│ │ ├── export_service.py # CSV/Excel exports stream via generator + yield_per(500)
|
||||
│ │ ├── fx_service.py
|
||||
│ │ ├── goal_service.py
|
||||
│ │ ├── import_service.py
|
||||
│ │ ├── investment_service.py # get_portfolio_summary returns account_groups
|
||||
│ │ ├── health_score_service.py # compute_health_score() — savings/budgets/goals/emergency fund → 0-100 score
|
||||
│ │ ├── alert_service.py # budget threshold alerts (flash + SMTP email), dedup via alert_sent_80/100
|
||||
│ │ ├── ocr_service.py
|
||||
│ │ ├── recurring_service.py
|
||||
│ │ ├── report_service.py
|
||||
│ │ ├── teller_service.py # auto_categorize; correct sign convention; live balance after sync
|
||||
│ │ ├── schwab_service.py # + expanded ACCOUNT_TYPE_MAP; refresh_token_expires_at always reset
|
||||
│ │ ├── plaid_service.py # Link token, exchange, accounts, balances, liabilities, cursor sync, parse, import
|
||||
│ │ ├── utility_service.py # bill roll-ups, usage/rate trends, payment matching
|
||||
│ │ └── bank_import_service.py
|
||||
│ │
|
||||
│ ├── templates/
|
||||
@@ -379,6 +439,8 @@ pfm/ # /home/pfm/web on server
|
||||
│ │ ├── schwab/ # index.html (+ ← Settings back btn), map_accounts.html, preview.html
|
||||
│ │ ├── plaid/ # index.html (+ ← Settings back btn), map_accounts.html, preview.html
|
||||
│ │ ├── logs/index.html # DB-backed viewer; Purge dropdown; ← Settings back btn moved to topbar
|
||||
│ │ ├── utilities/ # index.html, providers.html, provider_form.html, detail.html,
|
||||
│ │ │ # bills.html, bill_form.html, pay.html, link.html
|
||||
│ │ └── ... (other templates unchanged)
|
||||
│ │
|
||||
│ └── utils/
|
||||
@@ -399,6 +461,7 @@ pfm/ # /home/pfm/web on server
|
||||
│ ├── add_security_columns.py # adds TOTP cols, audit_logs table, widens token cols to TEXT
|
||||
│ ├── add_plaid_tables.py # creates plaid_items, plaid_accounts, plaid_sync_previews
|
||||
│ ├── add_log_tables.py # creates audit_logs + app_logs tables (safe to re-run)
|
||||
│ ├── add_utility_tables.py # creates utility_providers + utility_bills (safe to re-run)
|
||||
│ └── sync_schwab.py # daily Schwab auto-sync (balance + positions + transactions)
|
||||
│
|
||||
└── tests/
|
||||
@@ -526,7 +589,9 @@ sentry-sdk[flask]==2.7.0
|
||||
|
||||
## 12. UI/UX
|
||||
|
||||
- **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay; Teller Sync and Schwab Sync **removed** — accessible via Settings only
|
||||
- **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay; Teller Sync and Schwab Sync **removed** — accessible via Settings only; Utilities sits under Money with an amber badge counting bills due within 7 days (`utility_due_count`, set in the `inject_globals` context processor)
|
||||
- **Mobile responsive**: sidebar goes off-canvas with a dimmed overlay under 769px (topbar toggle button opens/closes it); topbar and main content collapse to full width; tables scroll horizontally via `.table-wrap` / `.pcard.p-0` wrapper classes + `.pfm-table` min-widths, with `.d-mob-none` hiding low-priority columns first; under 576px, button labels hide to icon-only (`.btn-label`) and chart/chat heights are capped (base.html)
|
||||
- **Dark mode**: toggle button in topbar (moon/sun icon); persisted via `localStorage['pfm_dark']`; applied pre-paint via a `data-pfm-dark` attribute to avoid flash-of-light-mode; CSS variable overrides plus targeted `[style*="..."]` overrides for hardcoded inline colors in templates (base.html)
|
||||
- **Charts**: Chart.js 4.x (CDN)
|
||||
- **Forms**: WTForms + Bootstrap 5.3
|
||||
- **Icons**: Bootstrap Icons 1.11
|
||||
@@ -535,6 +600,7 @@ sentry-sdk[flask]==2.7.0
|
||||
- **CSS**: All inline in templates (no build step)
|
||||
- **CSRF meta tag**: `<meta name="csrf-token">` in `base.html` for JS fetch calls
|
||||
- **Back buttons**: all Settings sub-pages have `← Settings` (or `← Recurring` for the recurring form) in `{% block topbar_actions %}`
|
||||
- **Keyboard shortcuts**: `n`/`i` new expense/income, `/` focus search, `g h`/`g t`/`g a` navigation chords, `?` shows cheatsheet modal (base.html)
|
||||
|
||||
---
|
||||
|
||||
@@ -603,17 +669,24 @@ PLAID_ENV=sandbox
|
||||
SENTRY_DSN= # leave blank to disable Sentry
|
||||
SESSION_IDLE_MINUTES=60 # session idle timeout in minutes
|
||||
RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits across Gunicorn workers
|
||||
# Budget alert emails (optional — leave blank to disable)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
ALERT_EMAIL= # recipient address for budget alert emails
|
||||
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 |
|
||||
|-----------|--------|------------|
|
||||
| health | (none) | /health (public, no auth) |
|
||||
| auth | /auth | login, logout, totp/verify, totp/setup, totp/disable |
|
||||
| dashboard | / | index, api/fx-history, api/fx-refresh, api/reconcile |
|
||||
| dashboard | / | index, api/fx-history, api/fx-refresh, api/reconcile, api/health-score |
|
||||
| accounts | /accounts | CRUD, adjust |
|
||||
| categories | /categories | CRUD |
|
||||
| transactions | /transactions | index, new, edit, delete, transfer, ocr, ocr-file, `<id>/set-category`, bulk-action |
|
||||
@@ -622,12 +695,13 @@ RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits a
|
||||
| investments | /investments | index, new, detail, edit, delete, add_transaction, refresh-prices, sync-schwab, api/price, api/daychange, api/price-history |
|
||||
| reports | /reports | monthly, quarterly, yearly, tax, export/csv\|excel\|pdf, snapshot |
|
||||
| ai | /ai | index, stream (SSE), history, generate-insight |
|
||||
| settings | /settings | index, profile, password, audit, audit/purge, recurring, import, recalc-balances, upload_receipt, delete_receipt, view_receipt |
|
||||
| settings | /settings | index, profile, password, test-email, audit, audit/purge, recurring, import, recalc-balances, upload_receipt, delete_receipt, view_receipt |
|
||||
| teller | /teller | callback, map, index, sync, sync/confirm, sync/all, balance, balance/all, resync, disconnect, webhook |
|
||||
| schwab | /schwab | connect, callback, index, map, sync/`<id>`, sync/confirm, resync, snapshot/`<id>`, disconnect |
|
||||
| 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) |
|
||||
| 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>` |
|
||||
|
||||
---
|
||||
|
||||
@@ -683,22 +757,26 @@ RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits a
|
||||
## 19. To-Do / Roadmap
|
||||
|
||||
### High priority
|
||||
- [ ] **Mobile responsiveness pass** — sidebar auto-collapses on mobile; tables scroll horizontally *(base.html mobile CSS improved; further work needed)*
|
||||
- [ ] **Budget alerts** — email/Twilio SMS when category spending hits 80% / 100%
|
||||
*(none currently open)*
|
||||
|
||||
### Medium priority
|
||||
- [ ] **PDF export memory** — stream CSV/Excel exports for users with large transaction history
|
||||
- [ ] **Receipt MIME validation** — validate file magic bytes server-side, not just extension
|
||||
- [ ] **OCR ownership check** — verify re-extracted filename belongs to current user's transaction
|
||||
- [ ] **Bank import progress** — show per-row import progress for large statement files
|
||||
*(none currently open)*
|
||||
|
||||
### Low priority / future
|
||||
- [ ] iOS companion app
|
||||
- [ ] Shared household mode (2 users, row-level isolation)
|
||||
- [ ] Bank statement PDF: table-extraction fallback (pdfplumber tables API) before Groq call
|
||||
- [ ] Dark mode toggle
|
||||
- [ ] Minor: bank-import column-mapping step (`.map-row` grid, fixed `160px 1fr`) feels cramped under ~360px viewport width — cosmetic only, not broken
|
||||
|
||||
### Completed (removed from backlog)
|
||||
- [x] **Mobile responsiveness pass** — sidebar off-canvas + overlay under 769px, topbar collapse, table horizontal scroll via `.table-wrap`/`.pcard.p-0` wrappers, `.d-mob-none` column hiding, icon-only buttons + capped chart/chat heights under 576px (base.html)
|
||||
- [x] **Dark mode toggle** — full implementation in base.html: toggle button, localStorage persistence, dark CSS variables, override rules for hardcoded inline colors
|
||||
- [x] **Budget alerts** — flash + email (SMTP) at 80%/100% of category budget, dedup flags, "Send test email" button ([2.18](#218-budget-alerts-email))
|
||||
- [x] **Financial Health Score** — 0–100 score/grade from savings rate, budget adherence, goal progress, emergency fund ([2.17](#217-financial-health-score))
|
||||
- [x] **Receipt MIME validation** — magic-byte sniffing in `settings.py`, rejects mismatched/renamed files
|
||||
- [x] **OCR ownership check** — re-extract requires filename to exist in `receipts` table
|
||||
- [x] **PDF export memory** — CSV/Excel exports now stream via generator + `yield_per(500)`
|
||||
- [x] **Bank import progress** — chunked import with live per-row progress bar
|
||||
- [x] **Pagination info** — "Page X of Y" added to transactions, AI history, accounts/payments, audit log
|
||||
- [x] **Schwab IRA account type** — IRA/ROTH_IRA/401K/BROKERAGE types added to ACCOUNT_TYPE_MAP
|
||||
- [x] **Investment price history chart** — 1W/1M/3M/6M/1Y chart on investment detail page
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PFM — Personal Finance Manager
|
||||
|
||||
A self-hosted personal finance web application. Track income, expenses, investments, budgets, and savings goals. AI-powered financial assistant and receipt OCR built in. Sync bank accounts via Teller or Schwab.
|
||||
A self-hosted personal finance web application. Track income, expenses, investments, budgets, and savings goals. AI-powered financial assistant and receipt OCR built in. Sync bank accounts via Teller, Plaid, or Schwab.
|
||||
|
||||
**Live at:** https://pfm.ngodanguyen.tech
|
||||
|
||||
@@ -20,13 +20,16 @@ A self-hosted personal finance web application. Track income, expenses, investme
|
||||
10. [AI Assistant](#ai-assistant)
|
||||
11. [Reports & Export](#reports--export)
|
||||
12. [Settings](#settings)
|
||||
13. [Recurring Transactions](#recurring-transactions)
|
||||
14. [CSV Import](#csv-import)
|
||||
15. [Receipt OCR](#receipt-ocr)
|
||||
16. [Teller Bank Sync](#teller-bank-sync)
|
||||
17. [Schwab Bank Sync](#schwab-bank-sync)
|
||||
18. [USD → VND Rate Widget](#usd--vnd-rate-widget)
|
||||
19. [Keyboard Shortcuts & Tips](#keyboard-shortcuts--tips)
|
||||
13. [Security & 2FA](#security--2fa)
|
||||
14. [Recurring Transactions](#recurring-transactions)
|
||||
15. [Bank Statement Import](#bank-statement-import)
|
||||
16. [Receipt OCR](#receipt-ocr)
|
||||
17. [Teller Bank Sync](#teller-bank-sync)
|
||||
18. [Schwab Bank Sync](#schwab-bank-sync)
|
||||
19. [Plaid Bank Sync](#plaid-bank-sync)
|
||||
20. [USD → VND Rate Widget](#usd--vnd-rate-widget)
|
||||
21. [System Logs](#system-logs)
|
||||
22. [Keyboard Shortcuts & Tips](#keyboard-shortcuts--tips)
|
||||
|
||||
---
|
||||
|
||||
@@ -34,19 +37,22 @@ A self-hosted personal finance web application. Track income, expenses, investme
|
||||
|
||||
| Module | What it does |
|
||||
|--------|-------------|
|
||||
| **Dashboard** | Net worth, cash flow chart, budget gauges, AI insight, USD/VND rate |
|
||||
| **Transactions** | Income + expense entry, transfer, filter, search, inline category change, receipt upload |
|
||||
| **Accounts** | Multiple bank/cash/credit accounts, auto-calculated or provider-synced balances |
|
||||
| **Dashboard** | Net worth, savings rate, cash flow chart, budget gauges, upcoming bills, reconcile, AI insight, USD/VND rate |
|
||||
| **Transactions** | Income/expense entry, transfer, inline category, bulk actions, quick date filters, duplicate detection, split transaction, filtered export |
|
||||
| **Accounts** | Multiple account types, auto-calculated or provider-synced balances (Teller / Plaid / Schwab) |
|
||||
| **Categories** | Custom expense + income categories with color and icon |
|
||||
| **Budgets** | Monthly spending limits per category with progress tracking |
|
||||
| **Goals** | Savings goals with contribution tracking and projected completion |
|
||||
| **Investments** | Stock/ETF/crypto/real estate portfolio, per-account view (Individual vs Roth IRA), live price fetch |
|
||||
| **AI Assistant** | Chat with your finances via Groq (free), daily auto-insight |
|
||||
| **Budgets** | Monthly spending limits with progress bars, budget vs actual chart, rollover support |
|
||||
| **Goals** | Savings goals with contribution tracking, projected completion, emergency fund tracker |
|
||||
| **Investments** | Stock/ETF/crypto portfolio, per-account view, price alerts, price history chart, live price fetch |
|
||||
| **AI Assistant** | Chat with your finances via Groq (free), daily auto-insight, streaming responses |
|
||||
| **Receipt OCR** | Drag-drop a receipt image — AI extracts and fills the form |
|
||||
| **Reports** | Monthly/quarterly/yearly summaries, net worth history, PDF/CSV/Excel export |
|
||||
| **Teller** | Live US bank account sync (mTLS), balance refresh, transaction import |
|
||||
| **Schwab** | Schwab brokerage sync — balance, stock/ETF positions, transaction import |
|
||||
| **Settings** | Profile, currency, recurring rules, CSV import, password |
|
||||
| **Reports** | Monthly/quarterly/yearly summaries, net worth history, category spending trends, PDF/CSV/Excel export |
|
||||
| **Teller** | Live US bank sync (mTLS), balance refresh, transaction import, webhook |
|
||||
| **Schwab** | Schwab brokerage sync — balance, stock/ETF positions, transaction import, daily auto-sync |
|
||||
| **Plaid** | 12,000+ US banks/credit unions via Plaid — cursor sync, credit card billing, webhook auto-import |
|
||||
| **Bank Import** | Upload PDF/OFX/QFX/CSV bank statements — auto-categorizes, preview before import |
|
||||
| **Security** | TOTP 2FA, session idle timeout, rate limiting, audit log, at-rest encryption for bank tokens |
|
||||
| **System Logs** | DB-backed application log viewer with colour-coded levels, search, module filter, and purge |
|
||||
|
||||
---
|
||||
|
||||
@@ -61,13 +67,14 @@ A self-hosted personal finance web application. Track income, expenses, investme
|
||||
### Recommended Setup Order
|
||||
|
||||
1. **Add accounts** — add your bank accounts, cash wallet, and credit cards first
|
||||
2. **Review categories** — default categories are already seeded; add custom ones if needed
|
||||
3. **Set your currency** — go to Settings → Profile and set your app currency
|
||||
4. **Connect bank sync** — connect Teller (US banks) or Schwab for automatic data
|
||||
5. **Add transactions** — start entering income and expenses (or import via bank sync)
|
||||
6. **Set budgets** — once you have categories, set monthly limits
|
||||
2. **Review categories** — 21 default categories are seeded; add custom ones if needed
|
||||
3. **Set your currency** — go to Settings → Profile and pick from 8 currency options
|
||||
4. **Connect bank sync** — connect Teller (US banks), Plaid (12,000+ institutions), or Schwab for automatic data
|
||||
5. **Add transactions** — enter income/expenses manually, import a bank statement, or sync from a provider
|
||||
6. **Set budgets** — once you have spending data, set monthly limits per category
|
||||
7. **Create goals** — add savings goals and start contributing
|
||||
8. **Add investments** — manually or via Schwab sync
|
||||
9. **Enable 2FA** — recommended: Settings → Security → Two-Factor Authentication
|
||||
|
||||
---
|
||||
|
||||
@@ -76,80 +83,131 @@ A self-hosted personal finance web application. Track income, expenses, investme
|
||||
The dashboard is the home screen — accessible from the sidebar or by clicking the PFM logo.
|
||||
|
||||
### Period Selector
|
||||
Three buttons in the top-right: **This Month**, **Last Month**, **Custom** (date range picker).
|
||||
Three buttons in the top-right: **This Month**, **Last Month**, **Custom** (date range picker). All summary cards update for the chosen period.
|
||||
|
||||
### Summary Cards
|
||||
- **Income** — total income for the selected period
|
||||
- **Expenses** — total expenses for the selected period
|
||||
- **Net Cash Flow** — income minus expenses
|
||||
- **Net Worth** — total assets minus liabilities across all accounts
|
||||
- **Savings Rate** — net cash flow ÷ income (green ≥ 20%, blue > 0%, red negative)
|
||||
- **Net Worth** — total assets minus liabilities
|
||||
- **Checking & Savings** — sum of checking, savings, and cash account balances
|
||||
- **Investments** — sum of investment and crypto account balances
|
||||
|
||||
### Reconcile Button
|
||||
Click **Reconcile** (next to period selector) to recalculate income, expenses, net cash flow, and savings rate while **excluding internal transfers**. This shows your true external cash flow. A notice shows the excluded transfer amounts and which categories were excluded. Click again to toggle back to the original totals.
|
||||
|
||||
### Cash Flow Chart
|
||||
Bar chart showing the last 6 months of income (green) vs expenses (red).
|
||||
|
||||
### Upcoming Bills
|
||||
Table of recurring rules due within the next 14 days — category icon, rule name, due date (color-coded: today/overdue in red, within 3 days in amber), amount, and account. Links to the Recurring page.
|
||||
|
||||
### Accounts Panel
|
||||
Lists all active accounts with current balances. Credit cards show the **Amount Owed** (positive number) instead of a raw negative balance. Green = positive balance, red = amount owed.
|
||||
Lists all active accounts with current balances. Credit cards show **Amount Owed** (positive) instead of a raw negative balance.
|
||||
|
||||
### AI Daily Insight
|
||||
Auto-generated summary of your finances. Click "Open AI →" for the full chat interface.
|
||||
Auto-generated summary of your finances at midnight. Click **Generate Now** for an on-demand insight. Click **Open AI →** for the full chat interface.
|
||||
|
||||
### USD → VND Widget
|
||||
Reference-only exchange rate. Click ↻ to refresh. Click the widget to show a 30-day chart.
|
||||
Reference-only exchange rate. Click ↻ to refresh without reloading. Click the widget to expand a 30-day trend chart. A ⚠ stale indicator appears if the rate is older than one day.
|
||||
|
||||
### Schwab Expiry Warning
|
||||
A banner appears on the dashboard when your Schwab refresh token expires within 2 days. Click the link to reconnect.
|
||||
|
||||
---
|
||||
|
||||
## Transactions
|
||||
|
||||
### Viewing Transactions
|
||||
Navigate via **Transactions** in the sidebar. Two tabs: **Expenses** and **Income**.
|
||||
Navigate via **Transactions** in the sidebar. Two tabs: **Expenses** and **Income** (with total counts).
|
||||
|
||||
### Filtering
|
||||
Search bar, category dropdown, account dropdown, date range. Click ✕ to clear all filters.
|
||||
- **Search** — matches description and notes fields
|
||||
- **Category** dropdown
|
||||
- **Account** dropdown
|
||||
- **Date range** — from/to pickers
|
||||
- **Amount range** — min/max amount
|
||||
- **Quick date filters** — "This Month" and "Last Month" buttons above the filter bar; active button is highlighted; ✕ clears the quick filter
|
||||
- **Saved filter presets** — save the current filter combination under a name; reload it from the dropdown in one click
|
||||
|
||||
### Plaid Review Banner
|
||||
When transactions are imported via Plaid webhook without a category, a purple banner at the top of the Transactions page shows the count and a link to review and categorize them. The sidebar Transactions link also shows a badge with the count.
|
||||
|
||||
### Adding a Transaction
|
||||
Use the sidebar links (Add Income / Add Expense), the dashboard quick-add buttons, or the topbar buttons on the transaction list.
|
||||
Use the topbar **Income** / **Expense** buttons, or use the sidebar links. The form includes:
|
||||
- Type toggle (Income / Expense) — switches available categories
|
||||
- Description, amount, date, account, category, notes
|
||||
- **Duplicate detection** — if the same amount on the same date already exists, a yellow warning banner appears before you save
|
||||
|
||||
### Inline Category Change
|
||||
On any transaction row, click the **category dropdown** directly in the table to change the category without opening the edit form. The change saves automatically.
|
||||
Click the **category dropdown** on any transaction row in the table to change the category. The change saves via AJAX — no page reload.
|
||||
|
||||
### Editing and Deleting
|
||||
Click **Edit** to open the full form (same as adding, plus receipt management). Click **Del** to delete permanently.
|
||||
Click **Edit** to open the full form. Click **Del** to delete permanently.
|
||||
|
||||
### Split Transaction
|
||||
Click the ✂ (scissors) icon on any transaction row to split it into multiple parts:
|
||||
- A split page shows the original transaction details and two default rows
|
||||
- Assign a different **category**, **description**, and **amount** to each part
|
||||
- A live **Remaining** counter shows how much is left to allocate
|
||||
- Add or remove rows as needed; the total must equal the original amount
|
||||
- On confirm, the original transaction is replaced by the individual split transactions
|
||||
|
||||
### Bulk Actions
|
||||
Check one or more transaction rows (or use the **select all** header checkbox) to reveal the bulk action toolbar:
|
||||
- **Set category** — apply a category to all selected rows at once; dropdowns update in the table without reload
|
||||
- **Delete** — delete all selected transactions after a confirmation dialog
|
||||
|
||||
### Export Filtered View
|
||||
In the filter bar, **CSV** and **Excel** buttons export the current filtered view (respects all active filters — search, category, account, date range, amount range). Exports are streamed for memory efficiency — no row limit.
|
||||
|
||||
### Transfers
|
||||
Click **Transfer** in the topbar. Creates a single transfer between two accounts. Transfers are excluded from income/expense totals.
|
||||
Click **Transfer** in the topbar. Creates a single transfer record between two accounts. Transfers are excluded from income/expense totals.
|
||||
|
||||
---
|
||||
|
||||
## Accounts
|
||||
|
||||
### Account Types
|
||||
checking / savings / cash / credit_card / crypto / investment / other
|
||||
`checking` / `savings` / `cash` / `credit_card` / `crypto` / `investment` / `other`
|
||||
|
||||
### Balance Sources
|
||||
- **Manually managed accounts** — balance is computed from all transactions (income − expenses − transfers out + transfers in). Updates automatically after every transaction.
|
||||
- **Teller-linked accounts** — balance comes from Teller's live API. Shown with a blue **Teller** badge. Use the action buttons on the account card.
|
||||
- **Schwab-linked accounts** — balance comes from Schwab's snapshot sync. Shown with a green **Schwab** badge. Use the action buttons on the account card.
|
||||
- **Manually managed** — balance is computed from transactions (income − expenses ± transfers). Updates after every transaction.
|
||||
- **Teller-linked** — live balance from Teller API. Blue **Teller** badge. Updated after sync or Refresh click.
|
||||
- **Schwab-linked** — balance from Schwab snapshot (liquidation value). Green **Schwab** badge. Updated after Balance & Positions click or daily auto-sync.
|
||||
- **Plaid-linked** — live balance from Plaid API. Purple **Plaid** badge. Updated after sync or Refresh click.
|
||||
|
||||
### Teller Account Actions (on account card)
|
||||
### Teller Account Actions
|
||||
| Button | What it does |
|
||||
|--------|-------------|
|
||||
| **Refresh** | Pulls the live balance from Teller API (AJAX, no page reload) |
|
||||
| **Sync** | Opens transaction preview — import new transactions from this account |
|
||||
| **Reset** | Clears the sync cursor — next Sync will re-fetch the full 90-day history |
|
||||
| **Refresh** | Pulls live balance from Teller API (AJAX) |
|
||||
| **Sync** | Opens transaction preview — import new transactions |
|
||||
| **Reset** | Clears sync cursor — next Sync re-fetches full 90-day history |
|
||||
|
||||
### Schwab Account Actions (on account card)
|
||||
### Schwab Account Actions
|
||||
| Button | What it does |
|
||||
|--------|-------------|
|
||||
| **Balance & Positions** | Fetches live balance + investment holdings from Schwab, updates immediately |
|
||||
| **Transactions** | Opens transaction import preview for this account |
|
||||
| **Balance & Positions** | Fetches live balance + investment holdings from Schwab |
|
||||
| **Transactions** | Opens transaction import preview |
|
||||
|
||||
### Plaid Account Actions
|
||||
| Button | What it does |
|
||||
|--------|-------------|
|
||||
| **Refresh** | Pulls live balance from Plaid API (AJAX) |
|
||||
| **Sync** | Opens transaction preview — import new transactions |
|
||||
| **Billing** | Fetches credit card due date, minimum payment, and statement balance |
|
||||
|
||||
### Plaid Credit Card Billing Card
|
||||
For Plaid-linked credit cards, a billing card on the account shows: due date, days remaining, minimum payment, and last statement balance. Updated by clicking **Billing**.
|
||||
|
||||
### Credit Cards
|
||||
Credit cards show two values:
|
||||
- **Amount Owed** — how much you currently owe (positive number in red)
|
||||
- **Amount Owed** — how much you currently owe (positive number)
|
||||
- **This Month** — expenses charged to the card this calendar month
|
||||
|
||||
### Opening Balance
|
||||
When creating a new account, enter the current balance in the **Opening Balance** field. For credit cards, enter a **negative number** to indicate debt (e.g. `-500` if you owe $500).
|
||||
When creating a new account, enter the current balance in the **Opening Balance** field. For credit cards, enter a **negative number** to indicate existing debt (e.g. `-500` if you owe $500).
|
||||
|
||||
---
|
||||
|
||||
@@ -161,7 +219,7 @@ Navigate via the sidebar footer → **Categories**.
|
||||
|
||||
**Default Income (7):** Salary, Freelance, Business, Investment, Rental, Gift Received, Other Income
|
||||
|
||||
System categories (grey badge) cannot be deleted but color/icon can be changed. Custom categories can be fully edited and deleted.
|
||||
System categories (grey badge) cannot be deleted — color and icon can still be changed. Custom categories can be fully edited and deleted.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,10 +227,20 @@ System categories (grey badge) cannot be deleted but color/icon can be changed.
|
||||
|
||||
Navigate via **Budgets** in the sidebar. Use ◀ ▶ to navigate months.
|
||||
|
||||
- Set monthly limits per expense category
|
||||
- Progress bars: green (< 80%) → amber (80–99%) → red (100%+)
|
||||
- Rollover toggle: unspent budget carries forward to next month
|
||||
- **Copy from previous month** — duplicate last month's budgets in one click
|
||||
### Setting Budgets
|
||||
Click **Add Budget** in the topbar to set a monthly limit for a category. Click **Edit** on an existing row to adjust.
|
||||
|
||||
### Budget vs Actual Chart
|
||||
A horizontal bar chart at the top of the page shows **Spent** (color-coded green/amber/red) vs **Budget limit** per category — for all categories that have a budget set this month.
|
||||
|
||||
### Progress Bars
|
||||
Each row shows a progress bar: green (< 80%) → amber (80–99%) → red (≥ 100%). Rows that exceed the limit have a red background tint.
|
||||
|
||||
### Rollover
|
||||
Enable rollover on a budget to carry unused amounts forward to the next month. A blue **+rollover** badge shows the carried-over amount on the row.
|
||||
|
||||
### Copy from Previous Month
|
||||
Click **Copy from [month]** at the top right to duplicate last month's budgets to the current month in one click. Also available on the empty-state screen.
|
||||
|
||||
---
|
||||
|
||||
@@ -181,9 +249,10 @@ Navigate via **Budgets** in the sidebar. Use ◀ ▶ to navigate months.
|
||||
Navigate via **Goals** in the sidebar.
|
||||
|
||||
- Create goals with target amount, target date, color, and icon
|
||||
- Add contributions with dates and notes
|
||||
- Auto-completes when target is reached
|
||||
- **Emergency Fund Tracker** — shows how many months your liquid assets cover at current spending
|
||||
- Add contributions with dates and notes; view contribution history
|
||||
- Progress bar + projected completion date (based on average monthly contribution)
|
||||
- Auto-completes when 100% is reached
|
||||
- **Emergency Fund Tracker** — shows how many months your liquid assets (checking + savings + cash accounts) cover at your current monthly spending rate, with 3-month and 6-month targets
|
||||
|
||||
---
|
||||
|
||||
@@ -192,25 +261,33 @@ Navigate via **Goals** in the sidebar.
|
||||
Navigate via **Investments** in the sidebar.
|
||||
|
||||
### Portfolio Overview
|
||||
Summary cards: Total Value, Total Cost, Unrealized P&L, Return %. Allocation doughnut chart by asset type.
|
||||
Summary cards: **Total Value**, **Total Cost**, **Unrealized P&L**, **Return %**. Allocation doughnut chart by asset type. Sidebar "By Account" breakdown.
|
||||
|
||||
### Price Alerts
|
||||
A yellow banner at the top of the Investments page appears when any holding moves ≥ 5% intraday. The banner lists each mover with its ticker and change percentage. Dismiss with ×. Alerts are recalculated daily when prices update.
|
||||
|
||||
### Investment Detail Page
|
||||
Click any holding to see the detail page:
|
||||
- Current price, day change, P&L
|
||||
- **Price history chart** — 1W / 1M / 3M / 6M / 1Y timeframes; fetched from Yahoo Finance
|
||||
- Full buy/sell/dividend/split transaction log
|
||||
|
||||
### Per-Account View
|
||||
If you have investments in multiple accounts (e.g. Schwab Individual + Schwab Roth IRA), the holdings are displayed in **separate sections**, one per account. Each section shows the account name, total account value, and the holdings table for that account. The allocation chart sidebar also shows a "By Account" balance breakdown.
|
||||
If investments span multiple accounts (e.g. Schwab Individual + Schwab Roth IRA), holdings are grouped into separate sections — one per account. Each section shows the account name, total value, and its own holdings table. Same ticker in different accounts stays as separate rows.
|
||||
|
||||
### Syncing from Schwab
|
||||
Click **Sync Schwab** in the topbar to pull the latest holdings and balances for all mapped Schwab accounts. After syncing, each holding is associated with its source account so Individual and Roth IRA positions stay separate even if they hold the same tickers.
|
||||
Click **Sync Schwab** in the topbar to pull the latest holdings and balances for all mapped Schwab accounts at once.
|
||||
|
||||
### Adding a Holding Manually
|
||||
Click **Add Holding** in the topbar. After saving, go to the detail page to record buy transactions.
|
||||
|
||||
### Ticker Format
|
||||
- US Stocks: `AAPL`, `MSFT`
|
||||
- ETFs: `VOO`, `QQQ`
|
||||
- US Stocks/ETFs: `AAPL`, `VOO`, `QQQ`
|
||||
- Crypto: `BTC-USD`, `ETH-USD`
|
||||
- Other markets: Yahoo Finance suffix (e.g. `VIC.VN`)
|
||||
- International: Yahoo Finance suffix (e.g. `VIC.VN`)
|
||||
|
||||
### Refreshing Prices
|
||||
Click **Refresh Prices** in the topbar. Prices also auto-update daily at 4PM (weekdays).
|
||||
Click **Refresh Prices** in the topbar. Prices also auto-update daily at 4 PM weekdays via cron.
|
||||
|
||||
---
|
||||
|
||||
@@ -218,15 +295,18 @@ Click **Refresh Prices** in the topbar. Prices also auto-update daily at 4PM (we
|
||||
|
||||
Navigate via **AI Assistant** in the sidebar.
|
||||
|
||||
The AI has access to the last 90 days of transactions, budget status, goals, investments, and net worth. No personal names or identifying details are sent to Groq — only aggregated financial figures.
|
||||
The AI has access to the last 90 days of transactions, budget status, goals, investments, and net worth. No personal names or identifiers are sent — only aggregated financial figures.
|
||||
|
||||
- Type and press **Enter** to send; **Shift+Enter** for new line
|
||||
- Eight quick-question suggestion buttons on the right
|
||||
- **Daily Insight** — auto-generated summary at midnight; click **Generate Now** to create on demand
|
||||
- **Chat History** button — all past responses with timestamps
|
||||
- Type a message and press **Enter** to send; **Shift+Enter** for a new line
|
||||
- Responses stream word-by-word (SSE)
|
||||
- Eight quick-question suggestion buttons
|
||||
- **Daily Insight** — auto-generated at midnight; click **Generate Now** to create on demand
|
||||
- **Chat History** — all past responses with timestamps
|
||||
|
||||
### AI Model
|
||||
Change in Settings → Profile → AI Model: `llama-3.3-70b-versatile` (best quality) or `llama-3.1-8b-instant` (faster).
|
||||
Change in Settings → Profile → AI Model:
|
||||
- `llama-3.3-70b-versatile` — best quality (default)
|
||||
- `llama-3.1-8b-instant` — faster, slightly lower quality
|
||||
|
||||
---
|
||||
|
||||
@@ -236,9 +316,14 @@ Navigate via **Reports** in the sidebar.
|
||||
|
||||
Four tabs: **Monthly**, **Quarterly**, **Yearly**, **Tax Year**.
|
||||
|
||||
Export buttons in the topbar: **CSV** (plain text), **Excel** (color-coded, formatted), **PDF** (printable report).
|
||||
Export buttons in the topbar:
|
||||
- **CSV** — plain text, streamed (no row limit)
|
||||
- **Excel** — color-coded (green = income, red = expense), formatted, streamed
|
||||
- **PDF** — printable report via WeasyPrint
|
||||
|
||||
**Snapshot Now** — saves today's net worth to the history chart.
|
||||
**Net Worth History** — line chart from monthly snapshots. **Snapshot Now** saves today's values immediately.
|
||||
|
||||
**Category Spending Trends** — top 6 categories over the last 6 months.
|
||||
|
||||
---
|
||||
|
||||
@@ -248,10 +333,44 @@ Navigate via the gear icon at the bottom of the sidebar.
|
||||
|
||||
| Section | What you can change |
|
||||
|---------|-------------------|
|
||||
| Profile | Display name, email, timezone, currency, AI model |
|
||||
| Password | Current password + new password (min 6 chars) |
|
||||
| Recurring | Create/edit/pause recurring transaction rules |
|
||||
| Import | Upload CSV to bulk-import transactions |
|
||||
| **Profile** | Display name, email, timezone, currency (8 options), AI model |
|
||||
| **Password** | Change password (requires current password) |
|
||||
| **Security** | Enable/disable TOTP 2FA; view Audit Log |
|
||||
| **Recurring** | Create/edit/pause recurring transaction rules |
|
||||
| **Import** | Upload CSV or bank statement for bulk import |
|
||||
| **System Logs** | View, search, and purge application logs |
|
||||
|
||||
All Settings sub-pages have a **← Settings** back button in the topbar.
|
||||
|
||||
---
|
||||
|
||||
## Security & 2FA
|
||||
|
||||
### Two-Factor Authentication (TOTP)
|
||||
1. Go to Settings → **Security** section
|
||||
2. Click **Enable Two-Factor Authentication**
|
||||
3. Scan the QR code with an authenticator app (Google Authenticator, Authy, etc.) or enter the manual key
|
||||
4. Enter the 6-digit code to confirm setup
|
||||
5. On future logins, you will be prompted for a 6-digit code after password entry
|
||||
|
||||
To disable 2FA: Settings → Security → **Disable** (requires password confirmation).
|
||||
|
||||
The TOTP verify endpoint is rate-limited (10/min, 30/hr). After 5 failed attempts in one session, the login is invalidated and you must start over.
|
||||
|
||||
### Session Idle Timeout
|
||||
Sessions expire after 60 minutes of inactivity (configurable via `SESSION_IDLE_MINUTES` in `.env`).
|
||||
|
||||
### Audit Log
|
||||
Settings → **Audit Log** shows a paginated, filterable log of security events with IP addresses and timestamps:
|
||||
- Login success / failure (with and without 2FA)
|
||||
- 2FA enabled / disabled
|
||||
- Password changed
|
||||
- Schwab connected / disconnected
|
||||
|
||||
Filter by event type. **Purge** dropdown removes entries older than 7, 30, or 90 days.
|
||||
|
||||
### At-Rest Encryption
|
||||
Teller, Schwab, and Plaid OAuth tokens are encrypted in the database using AES-128 (Fernet). Changing `SECRET_KEY` in `.env` invalidates stored tokens — all providers would need to reconnect.
|
||||
|
||||
---
|
||||
|
||||
@@ -259,78 +378,140 @@ Navigate via the gear icon at the bottom of the sidebar.
|
||||
|
||||
Navigate via Settings → **Recurring**.
|
||||
|
||||
Rules auto-create transactions on a schedule. Frequencies: daily, weekly, biweekly, monthly, quarterly, yearly. **Run Now** button processes all overdue rules immediately.
|
||||
Rules auto-create transactions on a schedule. Frequencies: `daily`, `weekly`, `biweekly`, `monthly`, `quarterly`, `yearly`.
|
||||
|
||||
- Set start date, optional end date, account, category, and amount
|
||||
- Pause/enable individual rules without deleting them
|
||||
- **Run Now** button processes all overdue rules immediately
|
||||
- **Upcoming (30 days)** panel shows next-due dates for all active rules
|
||||
- Rules also appear on the **Dashboard upcoming bills widget** when due within 14 days
|
||||
|
||||
---
|
||||
|
||||
## CSV Import
|
||||
## Bank Statement Import
|
||||
|
||||
Navigate via Settings → **Import**.
|
||||
Navigate via Settings → **Import Statement** (sidebar) or Settings → Import.
|
||||
|
||||
**Required columns:** `date`, `type` (income/expense), `description`, `amount`
|
||||
### Supported Formats
|
||||
| Format | Notes |
|
||||
|--------|-------|
|
||||
| **PDF** | Digital (text-based) PDFs only — scanned/image PDFs are rejected. Uses table extraction first; falls back to AI parsing via Groq if no structured table is found |
|
||||
| **OFX / QFX** | Standard Open Financial Exchange format (most US banks) |
|
||||
| **CSV** | Chase, BofA, Citi, Capital One, Discover, Amex, USAA, Wells Fargo, and Generic CSV |
|
||||
| **Custom CSV** | Map your own column headers when the bank format isn't recognized |
|
||||
|
||||
**Optional:** `category`, `account`, `notes`
|
||||
### Import Flow
|
||||
1. Upload the file
|
||||
2. **Preview table** — per-row checkboxes, editable category dropdowns, bulk category apply, bulk type toggle
|
||||
3. Click **Confirm Import** to write to the database
|
||||
|
||||
Process: upload → preview (with warnings for unmatched categories/accounts) → confirm.
|
||||
### Duplicate Detection
|
||||
- OFX/QFX: uses the `FITID` field
|
||||
- CSV/PDF: matches on date + amount + description + account
|
||||
|
||||
### Auto-Categorization
|
||||
200+ keyword rules automatically assign categories to imported transactions. Override any row in the preview before confirming.
|
||||
|
||||
---
|
||||
|
||||
## Receipt OCR
|
||||
|
||||
On the new expense form: drag-drop or click the purple panel to upload a receipt image (JPG/PNG/GIF/WEBP, max 10MB). The AI extracts amount, date, merchant, and category and fills the form. Always review before saving.
|
||||
On the **new expense form**: drag-drop or click the purple **AI Receipt Scanner** panel to upload a receipt image (JPG/PNG/GIF/WEBP, max 10MB). The AI extracts amount, date, merchant name, and category suggestion, then fills the form fields with a green flash animation. Always review before saving.
|
||||
|
||||
On the edit form: click **Re-extract** to re-run OCR on an already-attached receipt.
|
||||
On the **edit form**:
|
||||
- **Re-extract** button re-runs OCR on the already-attached receipt
|
||||
- OCR also runs automatically when you select a new image file in the upload field
|
||||
|
||||
---
|
||||
|
||||
## Teller Bank Sync
|
||||
|
||||
Teller connects US bank accounts using a secure mTLS connection.
|
||||
Teller connects US bank accounts using a secure mTLS connection. Accessible via **Settings** → Bank Connections.
|
||||
|
||||
### Connecting
|
||||
1. Go to **Bank Connections** in the sidebar
|
||||
1. Go to Settings → **Bank Connections**
|
||||
2. Click **Connect a Bank** — the Teller Connect modal opens
|
||||
3. Select your bank and log in
|
||||
4. After connecting, go to the **Map Accounts** page to link each Teller account to a PFM account
|
||||
4. After connecting, use **Map Account** on any unmapped accounts to link them to PFM accounts
|
||||
|
||||
### Syncing Transactions
|
||||
On the **Accounts** page, find your Teller-linked account (blue **Teller** badge) and click **Sync**. Review the transaction preview and confirm to import.
|
||||
On the **Accounts** page, find a Teller-linked account (blue **Teller** badge) and click **Sync**. Review the transaction preview and confirm to import. After import, the live balance is fetched from Teller automatically.
|
||||
|
||||
### Refreshing Balance
|
||||
Click **Refresh** on a Teller account card to pull the live balance from your bank. For checking/savings this shows the available balance; for credit cards it shows the amount owed.
|
||||
### Webhook
|
||||
Teller can push transaction updates to PFM automatically. The webhook endpoint verifies the `Teller-Signature` header (HMAC-SHA256) and has a 5-minute replay window.
|
||||
|
||||
### Disconnecting
|
||||
Go to **Bank Connections** → **Disconnect** next to the institution. Imported transactions are kept.
|
||||
Settings → Bank Connections → **Disconnect**. Imported transactions are kept.
|
||||
|
||||
---
|
||||
|
||||
## Schwab Bank Sync
|
||||
|
||||
Schwab integration uses OAuth 2.0 to sync brokerage and IRA accounts.
|
||||
Schwab integration uses OAuth 2.0 to sync brokerage and IRA accounts. Accessible via **Settings**.
|
||||
|
||||
### Connecting
|
||||
1. Set `SCHWAB_CLIENT_ID`, `SCHWAB_CLIENT_SECRET`, `SCHWAB_REDIRECT_URI` in `.env`
|
||||
2. Register the redirect URI in the Schwab developer portal
|
||||
3. Go to **Schwab** in the sidebar → **Connect Schwab Account**
|
||||
2. Register the redirect URI in the Schwab developer portal (exact match required)
|
||||
3. Settings → **Schwab** → **Connect Schwab Account**
|
||||
4. Authorize on Schwab's login page
|
||||
5. On the **Map Accounts** page, link each Schwab account to a PFM account (or create new ones)
|
||||
|
||||
### Syncing Balance & Investment Positions
|
||||
On the **Accounts** page, find your Schwab-linked account (green **Schwab** badge) and click **Balance & Positions**. This:
|
||||
- Updates the account's balance to Schwab's total portfolio value
|
||||
- Imports all stock/ETF/bond/fund holdings into the Investments section
|
||||
On the **Accounts** page, click **Balance & Positions** on a Schwab account (green badge). This:
|
||||
- Updates the account balance to Schwab's total portfolio liquidation value
|
||||
- Upserts stock/ETF/bond/fund holdings into the Investments section
|
||||
- Associates each holding with the specific account (Individual vs Roth IRA stay separate)
|
||||
|
||||
You can also click **Sync Schwab** in the Investments page topbar to update all Schwab accounts at once.
|
||||
Also click **Sync Schwab** in the Investments page topbar to update all Schwab accounts at once.
|
||||
|
||||
### Syncing Transactions
|
||||
Click **Transactions** on a Schwab account card. Review the preview and confirm to import.
|
||||
|
||||
### Per-Account Investment View
|
||||
After syncing both Individual and Roth IRA accounts, the Investments page shows separate sections for each account — even if both hold the same ticker (e.g. AAPL appears in both sections independently).
|
||||
### Token Expiry
|
||||
Schwab access tokens expire after 30 minutes (auto-refreshed). Refresh tokens last 7 days — a dashboard warning appears when ≤ 2 days remain. Reconnect via Settings → Schwab to reset the expiry.
|
||||
|
||||
### Daily Auto-Sync
|
||||
A cron job runs at 7 AM daily (`scripts/sync_schwab.py`) — updates balances, positions, and transactions for all mapped Schwab accounts automatically.
|
||||
|
||||
### Disconnecting
|
||||
Go to **Schwab** in the sidebar → **Disconnect**. Imported transactions and investment holdings are kept.
|
||||
Settings → Schwab → **Disconnect**. Imported transactions and holdings are kept.
|
||||
|
||||
---
|
||||
|
||||
## Plaid Bank Sync
|
||||
|
||||
Plaid connects to 12,000+ US banks and credit unions. Accessible via **Settings**.
|
||||
|
||||
### Connecting
|
||||
1. Set `PLAID_CLIENT_ID`, `PLAID_SECRET`, `PLAID_ENV` (`sandbox` or `production`) in `.env`
|
||||
2. Settings → **Plaid** → **Connect Bank Account**
|
||||
3. The Plaid Link widget opens — search for your institution and log in
|
||||
4. On the **Map Accounts** page, link each Plaid account to a PFM account (or create new ones)
|
||||
|
||||
### Syncing Transactions
|
||||
On the **Accounts** page, click **Sync** on a Plaid-linked account (purple badge). This uses cursor-based sync (`/transactions/sync`) — only new transactions since the last sync are fetched. Review the preview and confirm.
|
||||
|
||||
**Reset Sync** — click **Reset** to clear the sync cursor. The next sync re-fetches the full available history; duplicates are automatically skipped via the `Plaid:<transaction_id>` marker in notes.
|
||||
|
||||
### Live Balance Refresh
|
||||
Click **Refresh** on a Plaid account card to fetch the latest balance from Plaid's `/accounts/balance/get` endpoint.
|
||||
|
||||
### Credit Card Billing
|
||||
For Plaid-linked credit cards, click **Billing** on the account card to fetch:
|
||||
- Next payment due date
|
||||
- Minimum payment amount
|
||||
- Last statement balance
|
||||
- Overdue indicator
|
||||
|
||||
This information also appears in the billing card on the Accounts page.
|
||||
|
||||
### Webhook Auto-Import
|
||||
Plaid can push new transactions to PFM via webhook (`POST /plaid/webhook`). When a `TRANSACTIONS/SYNC` event arrives, new transactions are imported silently (no preview step). Transactions imported this way without a category appear in the **Plaid review** banner on the Transactions page and the sidebar badge — review and categorize them from there.
|
||||
|
||||
The webhook is verified using Plaid's JWT signature (ES256). Configure the webhook URL in Settings → Plaid → **Apply to Existing Items**.
|
||||
|
||||
### Disconnecting
|
||||
Settings → Plaid → **Disconnect** on a connection. Imported transactions are kept.
|
||||
|
||||
---
|
||||
|
||||
@@ -339,32 +520,57 @@ Go to **Schwab** in the sidebar → **Disconnect**. Imported transactions and in
|
||||
Reference-only exchange rate on the dashboard. Does not affect any app calculations.
|
||||
|
||||
- Click ↻ to force-refresh without reloading the page
|
||||
- Click the widget to show a 30-day trend chart
|
||||
- ⚠ indicator appears if the cached rate is more than a day old
|
||||
- Click the widget to expand a 30-day trend chart
|
||||
- Source label shown (yfinance / exchangerate-api)
|
||||
- ⚠ stale indicator appears if the cached rate is more than one day old
|
||||
|
||||
---
|
||||
|
||||
## System Logs
|
||||
|
||||
Navigate via Settings → **System Logs** (or sidebar → Settings → System Logs card).
|
||||
|
||||
Application log entries are stored in the database and shown in a colour-coded viewer:
|
||||
- Filter by level (INFO / WARNING / ERROR / DEBUG) or free-text search
|
||||
- Filter by module name (e.g. `app.teller`, `app.plaid`)
|
||||
- **Auto-refresh** toggle for live monitoring
|
||||
- **Download** — downloads the raw log file (`app.log`)
|
||||
- **Purge** dropdown — delete DB entries older than 7, 30, or 90 days
|
||||
- **Clear All** — truncates both the DB log table and the log file
|
||||
- DB entry count shown in the page header
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts & Tips
|
||||
|
||||
### Navigation
|
||||
- Sidebar collapses on desktop — click ☰ to toggle (state remembered)
|
||||
- On mobile, tap outside the sidebar to close it
|
||||
- Sidebar collapses on desktop — click ☰ to toggle (state saved in localStorage)
|
||||
- On mobile, tap outside the sidebar to close it; tables scroll horizontally
|
||||
|
||||
### Transactions
|
||||
- **Enter** sends in AI chat; **Shift+Enter** adds a new line
|
||||
- Change category directly in the transactions table — click the category dropdown on any row
|
||||
- Date defaults to today; change it for past transactions
|
||||
|
||||
### Investments
|
||||
- Click any row in the holdings table to go to the detail page
|
||||
- **↓** button on the transaction price field fetches the current live price
|
||||
- Ticker symbols are auto-uppercased on save
|
||||
- **Sync Schwab** (topbar) syncs all Schwab accounts at once
|
||||
- Change category directly in the transactions table — no form needed
|
||||
- Date defaults to today — change it for past transactions
|
||||
- Use the **✂ Split** button on a row to divide a transaction across multiple categories
|
||||
- Export the current filtered view with the **CSV** / **Excel** buttons in the filter bar
|
||||
|
||||
### Budgets
|
||||
- **Copy from previous month** button at the start of each month saves re-entering all budgets
|
||||
- Rollover amounts appear as a blue "+rollover" badge
|
||||
- **Copy from previous month** at the start of each month saves re-entering all budgets
|
||||
- The **Budget vs Actual** chart gives a quick visual of where you stand each month
|
||||
- Rollover amounts appear as a blue **+rollover** badge on the row
|
||||
|
||||
### Investments
|
||||
- Click any holding row to go to the detail page with P&L and price history chart
|
||||
- **↓** button on the new transaction price field fetches the live price for that ticker
|
||||
- Ticker symbols are auto-uppercased on save
|
||||
- Price alerts banner appears automatically when any holding moves ≥ 5% intraday
|
||||
|
||||
### Reports
|
||||
- **Snapshot Now** button saves today's net worth to the history chart
|
||||
- CSV/Excel exports use the period currently selected in the report view
|
||||
- **Snapshot Now** saves today's net worth to the history chart immediately
|
||||
- CSV and Excel exports from the Reports page use the selected period
|
||||
- From the Transactions page, use the filter bar export buttons to export any filtered view
|
||||
|
||||
### Security
|
||||
- Enable **2FA** in Settings → Security for additional login protection
|
||||
- The **Audit Log** (Settings → Security → Audit Log) records every login attempt and security event with IP address
|
||||
- Sessions time out after 60 minutes of inactivity
|
||||
|
||||
+29
-1
@@ -116,6 +116,7 @@ def create_app(config_name=None):
|
||||
from app.routes.plaid import plaid_bp
|
||||
from app.routes.logs import logs_bp
|
||||
from app.routes.bank_import import bank_import_bp
|
||||
from app.routes.utilities import utilities_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
@@ -134,13 +135,14 @@ def create_app(config_name=None):
|
||||
app.register_blueprint(plaid_bp)
|
||||
app.register_blueprint(logs_bp)
|
||||
app.register_blueprint(bank_import_bp)
|
||||
app.register_blueprint(utilities_bp)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import (
|
||||
User, Account, Category, Receipt, RecurringRule,
|
||||
Transaction, Budget, Goal, GoalContribution,
|
||||
Investment, InvestmentTransaction, NetWorthSnapshot,
|
||||
AiInsight, FxRate
|
||||
AiInsight, FxRate, UtilityProvider, UtilityBill
|
||||
)
|
||||
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||
from app.models.schwab_connection import SchwabConnection, SchwabAccount
|
||||
@@ -148,6 +150,32 @@ def create_app(config_name=None):
|
||||
from app.models.app_log import AppLog
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
# ── Context processor — global template vars ─────────────────────────────
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
from flask_login import current_user as _u
|
||||
ctx = {'plaid_review_count': 0, 'utility_due_count': 0}
|
||||
if _u.is_authenticated:
|
||||
try:
|
||||
from app.models.transaction import Transaction as _T
|
||||
ctx['plaid_review_count'] = _T.query.filter(
|
||||
_T.notes.like('Plaid:%'),
|
||||
_T.category_id.is_(None),
|
||||
).count()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from app.models.utility import UtilityBill as _UB, DUE_SOON_DAYS
|
||||
from datetime import date as _d, timedelta as _td
|
||||
ctx['utility_due_count'] = _UB.query.filter(
|
||||
_UB.is_paid == False,
|
||||
_UB.due_date.isnot(None),
|
||||
_UB.due_date <= _d.today() + _td(days=DUE_SOON_DAYS),
|
||||
).count()
|
||||
except Exception:
|
||||
pass
|
||||
return ctx
|
||||
|
||||
# ── Session idle timeout ──────────────────────────────────────────────────
|
||||
from flask import session as _session, request as _request
|
||||
from flask_login import current_user as _cu
|
||||
|
||||
@@ -10,5 +10,10 @@ from app.models.investment import Investment, InvestmentTransaction
|
||||
from app.models.net_worth_snapshot import NetWorthSnapshot
|
||||
from app.models.ai_insight import AiInsight
|
||||
from app.models.fx_rate import FxRate
|
||||
from app.models.utility import UtilityProvider, UtilityBill
|
||||
|
||||
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||
from app.models.schwab_connection import SchwabConnection, SchwabAccount
|
||||
from app.models.plaid_item import PlaidItem, PlaidAccount, PlaidSyncPreview
|
||||
from app.models.app_log import AppLog
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
@@ -11,6 +11,8 @@ class Budget(db.Model):
|
||||
limit_amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||
rollover_enabled = db.Column(db.Boolean, default=False)
|
||||
rollover_amount = db.Column(db.Numeric(15, 2), default=0.00)
|
||||
alert_sent_80 = db.Column(db.Boolean, default=False, nullable=False)
|
||||
alert_sent_100 = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
category = db.relationship('Category')
|
||||
|
||||
@@ -18,6 +18,7 @@ class User(UserMixin, db.Model):
|
||||
groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile')
|
||||
totp_secret = db.Column(db.String(64), nullable=True)
|
||||
totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||
budget_alerts_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
|
||||
@@ -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}>'
|
||||
+39
-1
@@ -5,11 +5,15 @@ from app.extensions import db
|
||||
from app.models.account import Account
|
||||
from app.models.transaction import Transaction
|
||||
from app.models.category import Category
|
||||
from app.models.recurring_rule import RecurringRule
|
||||
from app.services.fx_service import get_today_rate, get_rate_history, force_refresh
|
||||
from app.services.ai_service import get_latest_daily_insight
|
||||
from app.services.account_service import get_total_assets, get_total_liabilities
|
||||
from datetime import date, datetime, timedelta
|
||||
import calendar
|
||||
import logging
|
||||
|
||||
log = logging.getLogger('app.dashboard')
|
||||
|
||||
dashboard_bp = Blueprint('dashboard', __name__)
|
||||
|
||||
@@ -142,6 +146,14 @@ def index():
|
||||
chart_income.append(float(inc))
|
||||
chart_expense.append(float(exp))
|
||||
|
||||
# ── Upcoming bills (recurring rules due within 14 days) ──────────────────
|
||||
upcoming_bills = RecurringRule.query.filter(
|
||||
RecurringRule.is_active == True,
|
||||
RecurringRule.next_run != None,
|
||||
RecurringRule.next_run >= today,
|
||||
RecurringRule.next_run <= today + timedelta(days=14),
|
||||
).order_by(RecurringRule.next_run).limit(8).all()
|
||||
|
||||
# ── Recent transactions ───────────────────────────
|
||||
recent_txns = Transaction.query\
|
||||
.filter(Transaction.transaction_type.in_(['income', 'expense']))\
|
||||
@@ -182,7 +194,9 @@ def index():
|
||||
fx=fx,
|
||||
fx_history_data=fx_history_data,
|
||||
ai_insight=ai_insight,
|
||||
schwab_warning=schwab_warning)
|
||||
schwab_warning=schwab_warning,
|
||||
upcoming_bills=upcoming_bills,
|
||||
today=today)
|
||||
|
||||
|
||||
@dashboard_bp.route('/api/reconcile')
|
||||
@@ -250,6 +264,30 @@ def reconcile_api():
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/api/health-score')
|
||||
@login_required
|
||||
def health_score_api():
|
||||
from app.services.health_score_service import compute_health_score
|
||||
try:
|
||||
data = compute_health_score()
|
||||
except Exception as e:
|
||||
log.warning('[dashboard] health_score_api failed: %s', e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@dashboard_bp.route('/api/anomalies')
|
||||
@login_required
|
||||
def anomalies_api():
|
||||
from app.services.report_service import spending_anomalies
|
||||
try:
|
||||
items = spending_anomalies()
|
||||
except Exception as e:
|
||||
log.warning('[dashboard] anomalies_api failed: %s', e)
|
||||
items = []
|
||||
return jsonify({'anomalies': items})
|
||||
|
||||
|
||||
@dashboard_bp.route('/api/fx-history')
|
||||
@login_required
|
||||
def fx_history_api():
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models.investment import Investment, InvestmentTransaction
|
||||
from app.services.investment_service import (
|
||||
get_portfolio_summary, update_prices, fetch_price,
|
||||
fetch_price_history, fetch_day_change,
|
||||
get_price_alerts,
|
||||
ASSET_COLORS, ASSET_TYPE_LABELS
|
||||
)
|
||||
from datetime import date
|
||||
@@ -341,6 +342,13 @@ def api_day_change(ticker):
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@investments_bp.route('/api/price-alerts')
|
||||
@login_required
|
||||
def api_price_alerts():
|
||||
"""Return today's price-alert list (holdings that moved >= 5% intraday)."""
|
||||
return jsonify({'alerts': get_price_alerts()})
|
||||
|
||||
|
||||
@investments_bp.route('/api/history/<ticker>')
|
||||
@login_required
|
||||
def api_price_history(ticker):
|
||||
|
||||
+13
-10
@@ -1,14 +1,14 @@
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
Response, flash, send_file)
|
||||
Response, flash, send_file, stream_with_context)
|
||||
from flask_login import login_required, current_user
|
||||
from app.models.transaction import Transaction
|
||||
from app.services.report_service import (
|
||||
monthly_report, quarterly_report, yearly_report,
|
||||
net_worth_history, category_trends, tax_year_summary,
|
||||
take_net_worth_snapshot,
|
||||
take_net_worth_snapshot, category_mom_comparison,
|
||||
)
|
||||
from app.services.export_service import (
|
||||
transactions_to_csv, transactions_to_excel,
|
||||
transactions_csv_stream, transactions_to_excel,
|
||||
report_to_pdf, build_report_html,
|
||||
)
|
||||
from datetime import date
|
||||
@@ -24,16 +24,16 @@ _CUR_MONTH = date.today().month
|
||||
@login_required
|
||||
def index():
|
||||
today = date.today()
|
||||
# Default: current month summary
|
||||
report = monthly_report(today.year, today.month)
|
||||
nw_history = net_worth_history()
|
||||
cat_trend = category_trends(6)
|
||||
|
||||
mom_data = category_mom_comparison()
|
||||
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
||||
return render_template('reports/index.html',
|
||||
report=report,
|
||||
nw_history=nw_history,
|
||||
cat_trend=cat_trend,
|
||||
mom_data=mom_data,
|
||||
years=years,
|
||||
current_year=_CUR_YEAR,
|
||||
current_month=_CUR_MONTH,
|
||||
@@ -51,11 +51,13 @@ def monthly():
|
||||
report = monthly_report(year, month)
|
||||
nw_history = net_worth_history()
|
||||
cat_trend = category_trends(6)
|
||||
mom_data = category_mom_comparison()
|
||||
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
||||
return render_template('reports/index.html',
|
||||
report=report,
|
||||
nw_history=nw_history,
|
||||
cat_trend=cat_trend,
|
||||
mom_data=mom_data,
|
||||
years=years,
|
||||
current_year=_CUR_YEAR,
|
||||
current_month=_CUR_MONTH,
|
||||
@@ -78,6 +80,7 @@ def quarterly():
|
||||
report=report,
|
||||
nw_history=nw_history,
|
||||
cat_trend=cat_trend,
|
||||
mom_data=[],
|
||||
years=years,
|
||||
current_year=_CUR_YEAR,
|
||||
current_month=_CUR_MONTH,
|
||||
@@ -99,6 +102,7 @@ def yearly():
|
||||
report=report,
|
||||
nw_history=nw_history,
|
||||
cat_trend=cat_trend,
|
||||
mom_data=[],
|
||||
years=years,
|
||||
current_year=_CUR_YEAR,
|
||||
current_month=_CUR_MONTH,
|
||||
@@ -137,14 +141,13 @@ def export_csv():
|
||||
)
|
||||
if txn_type != 'all':
|
||||
query = query.filter(Transaction.transaction_type == txn_type)
|
||||
transactions = query.order_by(Transaction.date.desc()).all()
|
||||
query = query.order_by(Transaction.date.desc())
|
||||
|
||||
csv_data = transactions_to_csv(transactions)
|
||||
label = f'{year}-{month:02d}' if month else str(year)
|
||||
filename = f'pfm_transactions_{label}.csv'
|
||||
|
||||
return Response(
|
||||
csv_data,
|
||||
stream_with_context(transactions_csv_stream(query)),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
||||
)
|
||||
@@ -163,11 +166,11 @@ def export_excel():
|
||||
__import__('calendar').monthrange(year, month)[1]),
|
||||
Transaction.transaction_type.in_(['income', 'expense']),
|
||||
)
|
||||
transactions = query.order_by(Transaction.date.desc()).all()
|
||||
query = query.order_by(Transaction.date.desc())
|
||||
|
||||
label = f'{year}-{month:02d}' if month else str(year)
|
||||
period_label = f'{year} Month {month}' if month else str(year)
|
||||
excel_bytes = transactions_to_excel(transactions, period_label)
|
||||
excel_bytes = transactions_to_excel(query, period_label)
|
||||
filename = f'pfm_transactions_{label}.xlsx'
|
||||
|
||||
return send_file(
|
||||
|
||||
+36
-2
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import uuid
|
||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
request, current_app, send_from_directory)
|
||||
request, current_app, send_from_directory, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from app.utils.audit import audit
|
||||
from flask_wtf import FlaskForm
|
||||
@@ -68,6 +68,7 @@ class ProfileForm(FlaskForm):
|
||||
])
|
||||
currency = SelectField('Currency', choices=CURRENCIES)
|
||||
groq_model = SelectField('AI Model', choices=GROQ_MODELS)
|
||||
budget_alerts_enabled = BooleanField('Email me when a budget category reaches 80% or 100%')
|
||||
submit = SubmitField('Save Profile')
|
||||
|
||||
|
||||
@@ -156,10 +157,28 @@ def profile():
|
||||
current_user.currency = form.currency.data
|
||||
current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$')
|
||||
current_user.groq_model = form.groq_model.data
|
||||
current_user.budget_alerts_enabled = form.budget_alerts_enabled.data
|
||||
db.session.commit()
|
||||
flash('Profile updated.', 'success')
|
||||
return redirect(url_for('settings.profile'))
|
||||
return render_template('settings/profile.html', form=form)
|
||||
smtp_ok = all([
|
||||
current_app.config.get('SMTP_HOST'),
|
||||
current_app.config.get('SMTP_USER'),
|
||||
current_app.config.get('SMTP_PASSWORD'),
|
||||
current_app.config.get('ALERT_EMAIL'),
|
||||
])
|
||||
return render_template('settings/profile.html', form=form,
|
||||
smtp_ok=smtp_ok,
|
||||
alert_email=current_app.config.get('ALERT_EMAIL', ''))
|
||||
|
||||
|
||||
@settings_bp.route('/test-email', methods=['POST'])
|
||||
@login_required
|
||||
def test_email():
|
||||
from app.services.alert_service import send_test_email
|
||||
ok, msg = send_test_email()
|
||||
flash(msg, 'success' if ok else 'danger')
|
||||
return redirect(url_for('settings.profile'))
|
||||
|
||||
|
||||
@settings_bp.route('/password', methods=['GET', 'POST'])
|
||||
@@ -189,6 +208,21 @@ def recurring():
|
||||
return render_template('settings/recurring.html', rules=rules, upcoming=upcoming)
|
||||
|
||||
|
||||
@settings_bp.route('/recurring/projection')
|
||||
@login_required
|
||||
def recurring_projection():
|
||||
days = request.args.get('days', 30, type=int)
|
||||
if days not in (30, 60, 90):
|
||||
days = 30
|
||||
from app.services.recurring_service import projected_cash_flow
|
||||
data = projected_cash_flow(days)
|
||||
# Serialize events (date objects → string)
|
||||
data['events'] = [
|
||||
{**ev, 'date': ev['date'].strftime('%b %d')} for ev in data['events']
|
||||
]
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@settings_bp.route('/recurring/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def recurring_new():
|
||||
|
||||
+215
-30
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, Response, stream_with_context
|
||||
from flask_login import login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
|
||||
@@ -61,39 +61,17 @@ class TransferForm(FlaskForm):
|
||||
submit = SubmitField('Transfer')
|
||||
|
||||
|
||||
@transactions_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
tab = request.args.get('tab', 'expense') # 'income' | 'expense'
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search = request.args.get('q', '').strip()
|
||||
category_id = request.args.get('category_id', '', type=str)
|
||||
account_id = request.args.get('account_id', '', type=str)
|
||||
date_from = request.args.get('date_from', '')
|
||||
date_to = request.args.get('date_to', '')
|
||||
|
||||
from datetime import timedelta
|
||||
today = date.today()
|
||||
this_month_from = today.replace(day=1).strftime('%Y-%m-%d')
|
||||
this_month_to = today.strftime('%Y-%m-%d')
|
||||
last_month_last = today.replace(day=1) - timedelta(days=1)
|
||||
last_month_first = last_month_last.replace(day=1)
|
||||
last_month_from = last_month_first.strftime('%Y-%m-%d')
|
||||
last_month_to = last_month_last.strftime('%Y-%m-%d')
|
||||
|
||||
if date_from == this_month_from and date_to == this_month_to:
|
||||
active_quick = 'this_month'
|
||||
elif date_from == last_month_from and date_to == last_month_to:
|
||||
active_quick = 'last_month'
|
||||
else:
|
||||
active_quick = ''
|
||||
|
||||
def _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min='', amount_max=''):
|
||||
query = Transaction.query.filter(
|
||||
Transaction.transaction_type == tab
|
||||
).order_by(Transaction.date.desc(), Transaction.id.desc())
|
||||
|
||||
if search:
|
||||
query = query.filter(Transaction.description.ilike(f'%{search}%'))
|
||||
query = query.filter(
|
||||
or_(
|
||||
Transaction.description.ilike(f'%{search}%'),
|
||||
Transaction.notes.ilike(f'%{search}%'),
|
||||
)
|
||||
)
|
||||
try:
|
||||
if category_id:
|
||||
query = query.filter(Transaction.category_id == int(category_id))
|
||||
@@ -111,6 +89,46 @@ def index():
|
||||
query = query.filter(Transaction.date <= datetime.strptime(date_to, '%Y-%m-%d').date())
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
if amount_min:
|
||||
query = query.filter(Transaction.amount >= float(amount_min))
|
||||
if amount_max:
|
||||
query = query.filter(Transaction.amount <= float(amount_max))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return query
|
||||
|
||||
|
||||
@transactions_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
tab = request.args.get('tab', 'expense') # 'income' | 'expense'
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search = request.args.get('q', '').strip()
|
||||
category_id = request.args.get('category_id', '', type=str)
|
||||
account_id = request.args.get('account_id', '', type=str)
|
||||
date_from = request.args.get('date_from', '')
|
||||
date_to = request.args.get('date_to', '')
|
||||
amount_min = request.args.get('amount_min', '')
|
||||
amount_max = request.args.get('amount_max', '')
|
||||
|
||||
from datetime import timedelta
|
||||
today = date.today()
|
||||
this_month_from = today.replace(day=1).strftime('%Y-%m-%d')
|
||||
this_month_to = today.strftime('%Y-%m-%d')
|
||||
last_month_last = today.replace(day=1) - timedelta(days=1)
|
||||
last_month_first = last_month_last.replace(day=1)
|
||||
last_month_from = last_month_first.strftime('%Y-%m-%d')
|
||||
last_month_to = last_month_last.strftime('%Y-%m-%d')
|
||||
|
||||
if date_from == this_month_from and date_to == this_month_to:
|
||||
active_quick = 'this_month'
|
||||
elif date_from == last_month_from and date_to == last_month_to:
|
||||
active_quick = 'last_month'
|
||||
else:
|
||||
active_quick = ''
|
||||
|
||||
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
|
||||
|
||||
pagination = query.paginate(page=page, per_page=30, error_out=False)
|
||||
|
||||
@@ -137,6 +155,8 @@ def index():
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
amount_min=amount_min,
|
||||
amount_max=amount_max,
|
||||
active_quick=active_quick,
|
||||
this_month_from=this_month_from,
|
||||
this_month_to=this_month_to,
|
||||
@@ -427,12 +447,21 @@ def ocr_receipt_file():
|
||||
from app.models.category import Category
|
||||
from flask import current_app
|
||||
|
||||
from app.models.receipt import Receipt
|
||||
|
||||
data = request.get_json()
|
||||
if not data or not data.get('filename'):
|
||||
return jsonify({'error': 'No filename provided'}), 400
|
||||
|
||||
# Security: only allow basenames, no path traversal
|
||||
filename = os.path.basename(data['filename'])
|
||||
|
||||
# Ownership check: filename must exist in receipts table (single-user, but
|
||||
# prevents OCR extraction from arbitrary files on disk via a crafted request)
|
||||
receipt_record = Receipt.query.filter_by(filename=filename).first()
|
||||
if not receipt_record:
|
||||
return jsonify({'error': 'Receipt not found'}), 404
|
||||
|
||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
|
||||
@@ -458,3 +487,159 @@ def ocr_receipt_file():
|
||||
'category_suggestion': result['category_suggestion'],
|
||||
'category_id': category_id,
|
||||
})
|
||||
|
||||
|
||||
# ── Export filtered transactions ──────────────────────────────────────────────
|
||||
|
||||
@transactions_bp.route('/export/csv')
|
||||
@login_required
|
||||
def export_csv():
|
||||
from app.services.export_service import transactions_csv_stream
|
||||
tab = request.args.get('tab', 'expense')
|
||||
search = request.args.get('q', '').strip()
|
||||
category_id = request.args.get('category_id', '')
|
||||
account_id = request.args.get('account_id', '')
|
||||
date_from = request.args.get('date_from', '')
|
||||
date_to = request.args.get('date_to', '')
|
||||
amount_min = request.args.get('amount_min', '')
|
||||
amount_max = request.args.get('amount_max', '')
|
||||
|
||||
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
|
||||
filename = f'transactions_{tab}_{date.today().strftime("%Y%m%d")}.csv'
|
||||
return Response(
|
||||
stream_with_context(transactions_csv_stream(query)),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@transactions_bp.route('/export/excel')
|
||||
@login_required
|
||||
def export_excel():
|
||||
from app.services.export_service import transactions_to_excel
|
||||
tab = request.args.get('tab', 'expense')
|
||||
search = request.args.get('q', '').strip()
|
||||
category_id = request.args.get('category_id', '')
|
||||
account_id = request.args.get('account_id', '')
|
||||
date_from = request.args.get('date_from', '')
|
||||
date_to = request.args.get('date_to', '')
|
||||
amount_min = request.args.get('amount_min', '')
|
||||
amount_max = request.args.get('amount_max', '')
|
||||
|
||||
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
|
||||
label = f'{tab.title()} {date_from or "all"}{"-" + date_to if date_to else ""}'[:31]
|
||||
excel_bytes = transactions_to_excel(query, label)
|
||||
filename = f'transactions_{tab}_{date.today().strftime("%Y%m%d")}.xlsx'
|
||||
return Response(
|
||||
excel_bytes,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Duplicate detection ───────────────────────────────────────────────────────
|
||||
|
||||
@transactions_bp.route('/api/check-duplicate')
|
||||
@login_required
|
||||
def check_duplicate():
|
||||
txn_date = request.args.get('date', '')
|
||||
amount = request.args.get('amount', '')
|
||||
txn_type = request.args.get('type', 'expense')
|
||||
exclude_id = request.args.get('exclude_id', '', type=str)
|
||||
|
||||
if not txn_date or not amount:
|
||||
return jsonify({'duplicates': []})
|
||||
|
||||
try:
|
||||
d = datetime.strptime(txn_date, '%Y-%m-%d').date()
|
||||
amt = float(amount)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'duplicates': []})
|
||||
|
||||
q = Transaction.query.filter(
|
||||
Transaction.transaction_type == txn_type,
|
||||
Transaction.date == d,
|
||||
Transaction.amount == amt,
|
||||
)
|
||||
if exclude_id:
|
||||
try:
|
||||
q = q.filter(Transaction.id != int(exclude_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
dupes = q.limit(5).all()
|
||||
return jsonify({'duplicates': [
|
||||
{'id': t.id, 'description': t.description, 'date': t.date.strftime('%b %d, %Y'),
|
||||
'account': t.account.name if t.account else ''}
|
||||
for t in dupes
|
||||
]})
|
||||
|
||||
|
||||
# ── Split transaction ─────────────────────────────────────────────────────────
|
||||
|
||||
@transactions_bp.route('/<int:id>/split', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def split(id):
|
||||
txn = db.get_or_404(Transaction, id)
|
||||
|
||||
all_cats = Category.query.filter(
|
||||
Category.category_type.in_([txn.transaction_type, 'both']),
|
||||
Category.is_active == True,
|
||||
).order_by(Category.name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
amounts = request.form.getlist('split_amount')
|
||||
cat_ids = request.form.getlist('split_category')
|
||||
descs = request.form.getlist('split_description')
|
||||
|
||||
parts = []
|
||||
total_split = 0.0
|
||||
for amt_str, cid_str, desc_str in zip(amounts, cat_ids, descs):
|
||||
try:
|
||||
amt = float(amt_str)
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid amount in split.', 'danger')
|
||||
return redirect(url_for('transactions.split', id=id))
|
||||
if amt <= 0:
|
||||
continue
|
||||
parts.append({
|
||||
'amount': amt,
|
||||
'category_id': int(cid_str) if cid_str else None,
|
||||
'description': desc_str.strip() or txn.description,
|
||||
})
|
||||
total_split += amt
|
||||
|
||||
if not parts:
|
||||
flash('Add at least one split row.', 'danger')
|
||||
return redirect(url_for('transactions.split', id=id))
|
||||
|
||||
if abs(total_split - float(txn.amount)) > 0.005:
|
||||
flash(f'Split total {total_split:.2f} must equal original {float(txn.amount):.2f}.', 'danger')
|
||||
return redirect(url_for('transactions.split', id=id))
|
||||
|
||||
account_id = txn.account_id
|
||||
txn_type = txn.transaction_type
|
||||
txn_date = txn.date
|
||||
txn_notes = txn.notes
|
||||
|
||||
db.session.delete(txn)
|
||||
db.session.flush()
|
||||
|
||||
for part in parts:
|
||||
new_txn = Transaction(
|
||||
transaction_type=txn_type,
|
||||
account_id=account_id,
|
||||
category_id=part['category_id'],
|
||||
amount=part['amount'],
|
||||
description=part['description'],
|
||||
date=txn_date,
|
||||
notes=txn_notes,
|
||||
)
|
||||
db.session.add(new_txn)
|
||||
|
||||
db.session.commit()
|
||||
calc_balance(account_id)
|
||||
flash(f'Transaction split into {len(parts)} part(s).', 'success')
|
||||
return redirect(url_for('transactions.index', tab=txn_type))
|
||||
|
||||
return render_template('transactions/split.html', txn=txn, categories=all_cats)
|
||||
|
||||
@@ -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)))
|
||||
@@ -5,48 +5,45 @@ plus any incoming transfers minus outgoing transfers.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, case, or_, and_
|
||||
from app.extensions import db
|
||||
from app.models.account import Account
|
||||
from app.models.transaction import Transaction
|
||||
|
||||
|
||||
def calc_balance(account_id):
|
||||
"""Recalculate and persist the balance for a given account."""
|
||||
# Income credited to this account
|
||||
income = db.session.query(
|
||||
func.coalesce(func.sum(Transaction.amount), 0)
|
||||
"""
|
||||
Recalculate and persist the balance for a given account.
|
||||
Uses a single aggregation query with CASE expressions instead of 4 queries.
|
||||
"""
|
||||
row = db.session.query(
|
||||
func.coalesce(func.sum(case(
|
||||
(and_(Transaction.account_id == account_id,
|
||||
Transaction.transaction_type == 'income'), Transaction.amount),
|
||||
else_=0
|
||||
)), 0),
|
||||
func.coalesce(func.sum(case(
|
||||
(and_(Transaction.account_id == account_id,
|
||||
Transaction.transaction_type == 'expense'), Transaction.amount),
|
||||
else_=0
|
||||
)), 0),
|
||||
func.coalesce(func.sum(case(
|
||||
(and_(Transaction.account_id == account_id,
|
||||
Transaction.transaction_type == 'transfer'), Transaction.amount),
|
||||
else_=0
|
||||
)), 0),
|
||||
func.coalesce(func.sum(case(
|
||||
(and_(Transaction.to_account_id == account_id,
|
||||
Transaction.transaction_type == 'transfer'), Transaction.amount),
|
||||
else_=0
|
||||
)), 0),
|
||||
).filter(
|
||||
Transaction.account_id == account_id,
|
||||
Transaction.transaction_type == 'income'
|
||||
).scalar()
|
||||
or_(Transaction.account_id == account_id,
|
||||
Transaction.to_account_id == account_id)
|
||||
).one()
|
||||
|
||||
# Expenses debited from this account
|
||||
expense = db.session.query(
|
||||
func.coalesce(func.sum(Transaction.amount), 0)
|
||||
).filter(
|
||||
Transaction.account_id == account_id,
|
||||
Transaction.transaction_type == 'expense'
|
||||
).scalar()
|
||||
|
||||
# Transfers out (this account is source)
|
||||
transfer_out = db.session.query(
|
||||
func.coalesce(func.sum(Transaction.amount), 0)
|
||||
).filter(
|
||||
Transaction.account_id == account_id,
|
||||
Transaction.transaction_type == 'transfer'
|
||||
).scalar()
|
||||
|
||||
# Transfers in (this account is destination)
|
||||
transfer_in = db.session.query(
|
||||
func.coalesce(func.sum(Transaction.amount), 0)
|
||||
).filter(
|
||||
Transaction.to_account_id == account_id,
|
||||
Transaction.transaction_type == 'transfer'
|
||||
).scalar()
|
||||
|
||||
balance = Decimal(str(income)) - Decimal(str(expense)) \
|
||||
- Decimal(str(transfer_out)) + Decimal(str(transfer_in))
|
||||
income, expense, transfer_out, transfer_in = (Decimal(str(v)) for v in row)
|
||||
balance = income - expense - transfer_out + transfer_in
|
||||
|
||||
account = db.session.get(Account, account_id)
|
||||
if account:
|
||||
|
||||
+123
-43
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
Alert Service — budget threshold alerts triggered after expense transactions.
|
||||
|
||||
Checks all budgeted categories for the current month and returns alert dicts
|
||||
for any that just crossed 80% or 100%. Also sends an email if SMTP is configured.
|
||||
Checks budgeted categories and flashes/emails when spending crosses 80% or 100%.
|
||||
Dedup: alert_sent_80 / alert_sent_100 flags on each Budget row prevent repeat
|
||||
emails within the same month. Flags default to False on new Budget rows.
|
||||
|
||||
Usage (in routes after db.session.commit()):
|
||||
from app.services.alert_service import check_and_flash_budget_alerts
|
||||
@@ -17,7 +18,9 @@ from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from flask import current_app
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from app.extensions import db
|
||||
|
||||
log = logging.getLogger('app.alert_service')
|
||||
|
||||
|
||||
def _smtp_configured():
|
||||
@@ -35,7 +38,7 @@ def _send_email(subject, body_html):
|
||||
host = cfg['SMTP_HOST']
|
||||
port = int(cfg.get('SMTP_PORT', 587))
|
||||
user = cfg['SMTP_USER']
|
||||
password = cfg['SMTP_PASSWORD']
|
||||
pw = cfg['SMTP_PASSWORD']
|
||||
to_addr = cfg['ALERT_EMAIL']
|
||||
|
||||
msg = MIMEMultipart('alternative')
|
||||
@@ -45,31 +48,47 @@ def _send_email(subject, body_html):
|
||||
msg.attach(MIMEText(body_html, 'html'))
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
ctx = ssl.create_default_context()
|
||||
with smtplib.SMTP(host, port) as srv:
|
||||
srv.ehlo()
|
||||
srv.starttls(context=context)
|
||||
srv.login(user, password)
|
||||
srv.starttls(context=ctx)
|
||||
srv.login(user, pw)
|
||||
srv.sendmail(user, to_addr, msg.as_string())
|
||||
log.info('[alert] email sent: %s → %s', subject, to_addr)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.error('[alert] email failed: %s', exc, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def send_test_email():
|
||||
"""Send a test email to verify SMTP config. Returns (ok: bool, message: str)."""
|
||||
if not _smtp_configured():
|
||||
return False, ('SMTP not configured. '
|
||||
'Set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, and ALERT_EMAIL in .env.')
|
||||
to_addr = current_app.config['ALERT_EMAIL']
|
||||
body = (
|
||||
'<p>This is a test email from your <strong>Personal Finance Management</strong> app.</p>'
|
||||
'<p>Budget alerts are configured correctly.</p>'
|
||||
)
|
||||
ok = _send_email('PFM — Test Email', body)
|
||||
if ok:
|
||||
return True, f'Test email sent to {to_addr}.'
|
||||
return False, 'SMTP error — check server logs for details.'
|
||||
|
||||
|
||||
def get_budget_alerts(month_str=None):
|
||||
"""
|
||||
Return list of alert dicts for budget categories at or over threshold.
|
||||
Does NOT check dedup flags — use for display purposes only.
|
||||
|
||||
Each dict: {category, spent, limit, pct, level}
|
||||
level: 'over' (≥100%) | 'warning' (≥80%)
|
||||
|
||||
Only returns categories that have a budget set.
|
||||
"""
|
||||
from app.services.budget_service import get_budget_summary
|
||||
|
||||
if month_str is None:
|
||||
today = date.today()
|
||||
month_str = today.strftime('%Y-%m')
|
||||
month_str = date.today().strftime('%Y-%m')
|
||||
|
||||
summary = get_budget_summary(month_str)
|
||||
alerts = []
|
||||
@@ -81,56 +100,117 @@ def get_budget_alerts(month_str=None):
|
||||
alerts.append({**item, 'level': 'over'})
|
||||
elif pct >= 80:
|
||||
alerts.append({**item, 'level': 'warning'})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
def check_and_flash_budget_alerts(flash_fn):
|
||||
"""
|
||||
Check current month's budgets and flash any threshold alerts.
|
||||
Optionally sends an email summary if SMTP is configured.
|
||||
Check current month's budgets. For each category that newly crossed 80% or
|
||||
100%, flash a message and (if SMTP configured and user has alerts enabled)
|
||||
send one email. Dedup flags on the Budget row prevent repeat sends.
|
||||
|
||||
Call this after committing an expense transaction.
|
||||
|
||||
flash_fn: Flask's flash() function (passed in to avoid circular imports)
|
||||
flash_fn: Flask's flash() (passed in to avoid circular imports at module level)
|
||||
"""
|
||||
from app.models.budget import Budget
|
||||
from app.models.user import User
|
||||
from app.services.budget_service import get_month_spending
|
||||
|
||||
user = User.query.first()
|
||||
alerts_enabled = user.budget_alerts_enabled if user else False
|
||||
symbol = (user.currency_symbol or '$') if user else '$'
|
||||
|
||||
month_str = date.today().strftime('%Y-%m')
|
||||
|
||||
try:
|
||||
alerts = get_budget_alerts()
|
||||
budgets = Budget.query.filter_by(month=month_str).all()
|
||||
except Exception as exc:
|
||||
log.error('[alert] check failed: %s', exc, exc_info=True)
|
||||
log.error('[alert] failed to query budgets: %s', exc)
|
||||
return
|
||||
|
||||
if not alerts:
|
||||
return
|
||||
dirty_budgets = []
|
||||
|
||||
email_lines = []
|
||||
for a in alerts:
|
||||
cat_name = a['category'].name if a['category'] else 'Unknown'
|
||||
pct = a['pct']
|
||||
spent = a['spent']
|
||||
limit = a['limit']
|
||||
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
|
||||
for b in budgets:
|
||||
try:
|
||||
limit = float(b.limit_amount) + float(b.rollover_amount or 0)
|
||||
if limit <= 0:
|
||||
continue
|
||||
spent = get_month_spending(b.category_id, month_str)
|
||||
pct = spent / limit * 100
|
||||
cat = b.category
|
||||
cat_name = cat.name if cat else 'Unknown'
|
||||
|
||||
if a['level'] == 'over':
|
||||
# 100% threshold — only fire if not already sent this month
|
||||
if pct >= 100 and not b.alert_sent_100:
|
||||
msg = (f'Budget exceeded: <strong>{cat_name}</strong> — '
|
||||
f'{symbol}{spent:,.2f} spent of {symbol}{limit:,.2f} limit ({pct:.0f}%)')
|
||||
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}%)')
|
||||
flash_fn(msg, 'danger')
|
||||
else:
|
||||
if alerts_enabled and _smtp_configured():
|
||||
_send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold=100)
|
||||
b.alert_sent_100 = True
|
||||
dirty_budgets.append(b)
|
||||
|
||||
# 80% threshold — only fire if not already sent (and 100% not yet hit)
|
||||
elif pct >= 80 and not b.alert_sent_80:
|
||||
msg = (f'Budget warning: <strong>{cat_name}</strong> — '
|
||||
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
|
||||
flash_fn(msg, 'warning')
|
||||
if alerts_enabled and _smtp_configured():
|
||||
_send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold=80)
|
||||
b.alert_sent_80 = True
|
||||
dirty_budgets.append(b)
|
||||
|
||||
email_lines.append(
|
||||
f'<li><b>{cat_name}</b>: {symbol}{spent:,.2f} / {symbol}{limit:,.2f} '
|
||||
f'({pct:.0f}%) — <b>{"EXCEEDED" if a["level"]=="over" else "Warning"}</b></li>'
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error('[alert] error checking budget %s: %s', b.id, exc)
|
||||
|
||||
if _smtp_configured() and email_lines:
|
||||
today = date.today().strftime('%B %Y')
|
||||
body = (
|
||||
f'<h3>PFM Budget Alert — {today}</h3>'
|
||||
f'<p>The following budget categories need your attention:</p>'
|
||||
f'<ul>{"".join(email_lines)}</ul>'
|
||||
f'<p><a href="{current_app.config.get("APP_URL","")}/budgets">View Budgets →</a></p>'
|
||||
)
|
||||
_send_email(f'PFM Budget Alert — {today}', body)
|
||||
if dirty_budgets:
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as exc:
|
||||
log.error('[alert] failed to save alert flags: %s', exc)
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
def _send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold):
|
||||
label = '100%+' if threshold >= 100 else '80%'
|
||||
remaining = max(limit - spent, 0)
|
||||
color = '#ef4444' if threshold >= 100 else '#f59e0b'
|
||||
emoji = '🚨' if threshold >= 100 else '⚠️'
|
||||
app_url = current_app.config.get('APP_URL', '')
|
||||
|
||||
body = f"""
|
||||
<html><body style="font-family:sans-serif;color:#0f172a;max-width:520px;margin:0 auto;">
|
||||
<div style="background:#0f172a;padding:20px 24px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#f1f5f9;margin:0;font-size:18px;">{emoji} Budget Alert — {label}</h2>
|
||||
</div>
|
||||
<div style="border:1px solid #e2e8f0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
|
||||
<p style="font-size:15px;margin-top:0;">
|
||||
Your <strong>{cat_name}</strong> budget has reached
|
||||
<strong style="color:{color};">{pct:.0f}%</strong>.
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">
|
||||
<tr style="background:#f8fafc;">
|
||||
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Spent</td>
|
||||
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;color:#ef4444;">{symbol}{spent:,.2f}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Budget limit</td>
|
||||
<td style="padding:8px 12px;border:1px solid #e2e8f0;">{symbol}{limit:,.2f}</td>
|
||||
</tr>
|
||||
<tr style="background:#f8fafc;">
|
||||
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Remaining</td>
|
||||
<td style="padding:8px 12px;border:1px solid #e2e8f0;color:{'#ef4444' if remaining==0 else '#10b981'};">{symbol}{remaining:,.2f}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<a href="{app_url}/budgets"
|
||||
style="display:inline-block;background:#3b82f6;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:13px;">
|
||||
View Budgets →
|
||||
</a>
|
||||
<p style="font-size:11px;color:#94a3b8;margin-top:20px;margin-bottom:0;">
|
||||
Disable alerts in Settings → Profile.
|
||||
</p>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
subject = f'Budget Alert: {cat_name} at {label} ({pct:.0f}% used)'
|
||||
_send_email(subject, body)
|
||||
|
||||
@@ -436,6 +436,11 @@ def _build_cat_id_map():
|
||||
return {c.name: c.id for c in Category.query.filter_by(is_active=True).all()}
|
||||
|
||||
|
||||
def build_category_map():
|
||||
"""Public alias — shared by teller, plaid, and schwab services."""
|
||||
return _build_cat_id_map()
|
||||
|
||||
|
||||
# ── Enrichment ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _enrich(raw_rows):
|
||||
@@ -572,9 +577,115 @@ def _groq_parse_statement(text):
|
||||
return raw_rows
|
||||
|
||||
|
||||
_TABLE_DATE_HDRS = {'date', 'posted', 'transaction date', 'trans date',
|
||||
'posting date', 'value date', 'effective date', 'settled'}
|
||||
_TABLE_DESC_HDRS = {'description', 'payee', 'merchant', 'memo', 'transaction',
|
||||
'details', 'name', 'narrative', 'particulars', 'reference'}
|
||||
_TABLE_DEBIT_HDRS = {'debit', 'withdrawal', 'withdrawals', 'charge', 'charges',
|
||||
'amount debited', 'payment', 'dr'}
|
||||
_TABLE_CRED_HDRS = {'credit', 'deposit', 'deposits', 'amount credited',
|
||||
'cr', 'inflow'}
|
||||
_TABLE_AMT_HDRS = {'amount', 'transaction amount', 'net amount'}
|
||||
|
||||
|
||||
def _pdfplumber_table_parse(pdf_handle):
|
||||
"""
|
||||
Try to extract transactions directly from pdfplumber table structures.
|
||||
|
||||
Iterates every page, finds tables whose headers match bank-statement
|
||||
patterns, and converts rows to raw transaction dicts.
|
||||
|
||||
Returns a (possibly empty) list of raw dicts; never raises.
|
||||
"""
|
||||
all_rows = []
|
||||
|
||||
for page in pdf_handle.pages:
|
||||
try:
|
||||
tables = page.extract_tables()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for table in tables:
|
||||
if not table or len(table) < 2:
|
||||
continue
|
||||
|
||||
# Normalise headers (lower-case, strip)
|
||||
raw_headers = [str(h).strip().lower() if h else '' for h in table[0]]
|
||||
|
||||
date_col = next((i for i, h in enumerate(raw_headers)
|
||||
if h in _TABLE_DATE_HDRS), None)
|
||||
desc_col = next((i for i, h in enumerate(raw_headers)
|
||||
if h in _TABLE_DESC_HDRS), None)
|
||||
amt_col = next((i for i, h in enumerate(raw_headers)
|
||||
if h in _TABLE_AMT_HDRS), None)
|
||||
debit_col = next((i for i, h in enumerate(raw_headers)
|
||||
if h in _TABLE_DEBIT_HDRS), None)
|
||||
cred_col = next((i for i, h in enumerate(raw_headers)
|
||||
if h in _TABLE_CRED_HDRS), None)
|
||||
|
||||
# Need at least date + description + one amount column
|
||||
if date_col is None or desc_col is None:
|
||||
continue
|
||||
if amt_col is None and debit_col is None and cred_col is None:
|
||||
continue
|
||||
|
||||
col_max = max(c for c in [date_col, desc_col, amt_col, debit_col, cred_col]
|
||||
if c is not None)
|
||||
|
||||
for row in table[1:]:
|
||||
if not row or len(row) <= col_max:
|
||||
continue
|
||||
|
||||
date_str = str(row[date_col]).strip() if row[date_col] else ''
|
||||
if not date_str or date_str.lower() in ('', 'none', '-', '--', 'n/a'):
|
||||
continue
|
||||
try:
|
||||
txn_date = _parse_date(date_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
description = str(row[desc_col]).strip() if row[desc_col] else ''
|
||||
if not description or description.lower() in ('', 'none'):
|
||||
continue
|
||||
|
||||
if debit_col is not None or cred_col is not None:
|
||||
debit = abs(_clean_amount(row[debit_col] if debit_col is not None else ''))
|
||||
credit = abs(_clean_amount(row[cred_col] if cred_col is not None else ''))
|
||||
if debit > 0:
|
||||
amount, txn_type = debit, 'expense'
|
||||
elif credit > 0:
|
||||
amount, txn_type = credit, 'income'
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
raw_amt = _clean_amount(row[amt_col] if row[amt_col] else '')
|
||||
if raw_amt == 0.0:
|
||||
continue
|
||||
txn_type = 'expense' if raw_amt < 0 else 'income'
|
||||
amount = abs(raw_amt)
|
||||
|
||||
all_rows.append({
|
||||
'date': txn_date,
|
||||
'transaction_type': txn_type,
|
||||
'amount': amount,
|
||||
'description': description,
|
||||
'notes': '',
|
||||
'source_id': None,
|
||||
})
|
||||
|
||||
return all_rows
|
||||
|
||||
|
||||
def _parse_pdf(file_bytes):
|
||||
"""
|
||||
Extract text from a digital PDF using pdfplumber, then parse with Groq.
|
||||
Extract transactions from a digital bank-statement PDF.
|
||||
|
||||
Strategy (in order):
|
||||
1. pdfplumber table extraction — fast, free, no API call needed.
|
||||
Used when structured tables with recognisable headers are found and
|
||||
yield at least 3 rows.
|
||||
2. pdfplumber text extraction → Groq LLM — handles unstructured
|
||||
or narrative-style statements.
|
||||
|
||||
Returns (raw_rows, warnings_list).
|
||||
Raises RuntimeError for unrecoverable problems (scanned PDF, bad file, etc.).
|
||||
@@ -593,10 +704,27 @@ def _parse_pdf(file_bytes):
|
||||
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
||||
num_pages = len(pdf.pages)
|
||||
log.info('[bank_import] PDF has %d page(s)', num_pages)
|
||||
|
||||
# ── Strategy 1: structured table extraction ──────────────────────
|
||||
table_rows = _pdfplumber_table_parse(pdf)
|
||||
if len(table_rows) >= 3:
|
||||
log.info(
|
||||
'[bank_import] PDF table extraction: %d rows (skipping Groq)',
|
||||
len(table_rows),
|
||||
)
|
||||
return table_rows, warnings
|
||||
|
||||
log.info(
|
||||
'[bank_import] PDF table extraction yielded %d row(s) — falling back to Groq',
|
||||
len(table_rows),
|
||||
)
|
||||
|
||||
# ── Strategy 2: text extraction → Groq ──────────────────────────
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text(x_tolerance=2, y_tolerance=2)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
|
||||
except Exception as exc:
|
||||
log.error('[bank_import] pdfplumber failed: %s', exc, exc_info=True)
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
Export Service — CSV, Excel, and PDF generation for transactions and reports.
|
||||
|
||||
Memory-efficient exports:
|
||||
- CSV: streaming generator (rows written one at a time, never all in memory)
|
||||
- Excel: openpyxl write-only mode + DB yield_per(500) avoids loading the full
|
||||
result set into Python at once
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -11,13 +16,20 @@ from app.models.transaction import Transaction
|
||||
|
||||
# ── CSV export ────────────────────────────────────────────────────────────────
|
||||
|
||||
def transactions_to_csv(transactions):
|
||||
"""Return a CSV string of transactions."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
def transactions_csv_stream(query):
|
||||
"""
|
||||
Generator that yields CSV text one row at a time.
|
||||
Pass the SQLAlchemy *query* (not a list) — rows are fetched in 500-row batches.
|
||||
Use with Flask's stream_with_context() for a true streaming response.
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
|
||||
writer.writerow(['Date', 'Type', 'Description', 'Category', 'Account', 'Amount', 'Notes'])
|
||||
for txn in transactions:
|
||||
yield buf.getvalue()
|
||||
buf.seek(0); buf.truncate()
|
||||
|
||||
for txn in query.yield_per(500):
|
||||
writer.writerow([
|
||||
txn.date.strftime('%Y-%m-%d'),
|
||||
txn.transaction_type,
|
||||
@@ -27,80 +39,87 @@ def transactions_to_csv(transactions):
|
||||
float(txn.amount),
|
||||
txn.notes or '',
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
yield buf.getvalue()
|
||||
buf.seek(0); buf.truncate()
|
||||
|
||||
|
||||
# ── Excel export ──────────────────────────────────────────────────────────────
|
||||
|
||||
def transactions_to_excel(transactions, period_label='Transactions'):
|
||||
"""Return Excel bytes for a list of transactions."""
|
||||
def transactions_to_excel(query, period_label='Transactions'):
|
||||
"""
|
||||
Return Excel bytes built from a SQLAlchemy *query* using openpyxl write-only
|
||||
mode. Rows are fetched 500 at a time so the full result set is never held in
|
||||
Python memory simultaneously.
|
||||
"""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from openpyxl.cell import WriteOnlyCell
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = period_label[:31] # max 31 chars
|
||||
|
||||
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
|
||||
|
||||
# Header style
|
||||
wb = Workbook(write_only=True)
|
||||
ws = wb.create_sheet(title=period_label[:31])
|
||||
|
||||
col_widths = [12, 10, 40, 18, 18, 16, 30]
|
||||
for i, w in enumerate(col_widths, 1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = w
|
||||
|
||||
header_fill = PatternFill(start_color='0F172A', end_color='0F172A', fill_type='solid')
|
||||
header_font = Font(color='F1F5F9', bold=True, size=10)
|
||||
thin = Side(style='thin', color='E2E8F0')
|
||||
border = Border(bottom=Side(style='thin', color='E2E8F0'))
|
||||
headers = ['Date', 'Type', 'Description', 'Category', 'Account',
|
||||
f'Amount ({symbol})', 'Notes']
|
||||
header_row = []
|
||||
for h in headers:
|
||||
c = WriteOnlyCell(ws, value=h)
|
||||
c.font = header_font
|
||||
c.fill = header_fill
|
||||
header_row.append(c)
|
||||
ws.append(header_row)
|
||||
|
||||
headers = ['Date', 'Type', 'Description', 'Category', 'Account', f'Amount ({symbol})', 'Notes']
|
||||
col_widths = [12, 10, 40, 18, 18, 16, 30]
|
||||
|
||||
for col, (header, width) in enumerate(zip(headers, col_widths), 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.column_dimensions[get_column_letter(col)].width = width
|
||||
|
||||
ws.row_dimensions[1].height = 22
|
||||
|
||||
# Data rows
|
||||
income_fill = PatternFill(start_color='F0FDF4', end_color='F0FDF4', fill_type='solid')
|
||||
expense_fill = PatternFill(start_color='FFF7F7', end_color='FFF7F7', fill_type='solid')
|
||||
row_font = Font(size=10)
|
||||
amt_fmt = '#,##0.00'
|
||||
|
||||
for row_num, txn in enumerate(transactions, 2):
|
||||
running_total = 0.0
|
||||
for txn in query.yield_per(500):
|
||||
fill = income_fill if txn.transaction_type == 'income' else expense_fill
|
||||
data = [
|
||||
amt = float(txn.amount)
|
||||
running_total += amt
|
||||
row_vals = [
|
||||
txn.date.strftime('%Y-%m-%d'),
|
||||
txn.transaction_type.title(),
|
||||
txn.description,
|
||||
txn.category.name if txn.category else '',
|
||||
txn.account.name if txn.account else '',
|
||||
float(txn.amount),
|
||||
amt,
|
||||
txn.notes or '',
|
||||
]
|
||||
for col, value in enumerate(data, 1):
|
||||
cell = ws.cell(row=row_num, column=col, value=value)
|
||||
cell.fill = fill
|
||||
cell.border = border
|
||||
cell.font = Font(size=10)
|
||||
if col == 6:
|
||||
cell.number_format = f'#,##0.00'
|
||||
cell.alignment = Alignment(horizontal='right')
|
||||
row = []
|
||||
for col_idx, value in enumerate(row_vals, 1):
|
||||
c = WriteOnlyCell(ws, value=value)
|
||||
c.font = row_font
|
||||
c.fill = fill
|
||||
if col_idx == 6:
|
||||
c.number_format = amt_fmt
|
||||
c.alignment = Alignment(horizontal='right')
|
||||
row.append(c)
|
||||
ws.append(row)
|
||||
|
||||
# Totals row
|
||||
total_row = len(transactions) + 2
|
||||
ws.cell(row=total_row, column=5, value='TOTAL').font = Font(bold=True, size=10)
|
||||
total_cell = ws.cell(row=total_row, column=6,
|
||||
value=sum(float(t.amount) for t in transactions))
|
||||
total_cell.font = Font(bold=True, size=10)
|
||||
total_cell.number_format = f'#,##0.00'
|
||||
total_cell.alignment = Alignment(horizontal='right')
|
||||
# Totals row — plain cells (write-only, no random access)
|
||||
total_lbl = WriteOnlyCell(ws, value='TOTAL')
|
||||
total_lbl.font = Font(bold=True, size=10)
|
||||
total_val = WriteOnlyCell(ws, value=running_total)
|
||||
total_val.font = Font(bold=True, size=10)
|
||||
total_val.number_format = amt_fmt
|
||||
total_val.alignment = Alignment(horizontal='right')
|
||||
ws.append(['', '', '', '', total_lbl, total_val, ''])
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
return output.read()
|
||||
|
||||
|
||||
# ── PDF export ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Financial Health Score — synthesises savings rate, budget adherence, goal
|
||||
progress, and emergency fund coverage into a single 0–100 score.
|
||||
|
||||
Each component is worth 25 points. Returns the total score plus a breakdown
|
||||
so the UI can show per-component detail and suggestions.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy import func
|
||||
from app.extensions import db
|
||||
from app.models.transaction import Transaction
|
||||
from app.models.goal import Goal
|
||||
|
||||
|
||||
# ── Component scorers ────────────────────────────────────────────────────────
|
||||
|
||||
def _score_savings(max_pts=25):
|
||||
"""
|
||||
Avg savings rate over the last 3 full months.
|
||||
≥ 20% → full marks; 10–20% → 18; 1–10% → 10; ≤ 0% → 0.
|
||||
"""
|
||||
today = date.today()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# Collect last 3 complete months
|
||||
incomes, expenses = [], []
|
||||
for i in range(1, 4):
|
||||
mo_end = (month_start - relativedelta(days=1))
|
||||
mo_start = mo_end.replace(day=1)
|
||||
month_start = mo_start
|
||||
|
||||
inc = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0))
|
||||
.filter(Transaction.transaction_type == 'income',
|
||||
Transaction.date >= mo_start,
|
||||
Transaction.date <= mo_end).scalar())
|
||||
exp = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0))
|
||||
.filter(Transaction.transaction_type == 'expense',
|
||||
Transaction.date >= mo_start,
|
||||
Transaction.date <= mo_end).scalar())
|
||||
incomes.append(inc)
|
||||
expenses.append(exp)
|
||||
|
||||
total_inc = sum(incomes)
|
||||
total_exp = sum(expenses)
|
||||
rate = ((total_inc - total_exp) / total_inc * 100) if total_inc > 0 else 0
|
||||
|
||||
if rate >= 20:
|
||||
pts = max_pts
|
||||
elif rate >= 10:
|
||||
pts = round(max_pts * 0.72) # 18/25
|
||||
elif rate > 0:
|
||||
pts = round(max_pts * 0.40) # 10/25
|
||||
else:
|
||||
pts = 0
|
||||
|
||||
return {
|
||||
'points': pts,
|
||||
'max': max_pts,
|
||||
'value': round(rate, 1),
|
||||
'label': f'{rate:+.1f}% savings rate (3-mo avg)',
|
||||
'tip': None if rate >= 20 else (
|
||||
'Aim for 20%+ savings rate.' if rate < 10 else
|
||||
'Good start — push toward 20%.'),
|
||||
}
|
||||
|
||||
|
||||
def _score_budgets(max_pts=25):
|
||||
"""
|
||||
What fraction of budgeted categories are currently under their limit?
|
||||
All under → full marks; scales linearly.
|
||||
"""
|
||||
from app.services.budget_service import get_budget_summary
|
||||
month_str = date.today().strftime('%Y-%m')
|
||||
summary = [s for s in get_budget_summary(month_str) if s['has_budget']]
|
||||
|
||||
if not summary:
|
||||
return {
|
||||
'points': max_pts, # no budgets set → not penalised
|
||||
'max': max_pts,
|
||||
'value': None,
|
||||
'label': 'No budgets set',
|
||||
'tip': 'Set monthly budgets to track spending limits.',
|
||||
}
|
||||
|
||||
under = sum(1 for s in summary if not s['is_over'])
|
||||
ratio = under / len(summary)
|
||||
pts = round(ratio * max_pts)
|
||||
|
||||
over_cats = [s['category'].name for s in summary if s['is_over']]
|
||||
tip = None
|
||||
if over_cats:
|
||||
tip = f'Over budget: {", ".join(over_cats[:3])}{"…" if len(over_cats) > 3 else ""}.'
|
||||
|
||||
return {
|
||||
'points': pts,
|
||||
'max': max_pts,
|
||||
'value': round(ratio * 100, 1),
|
||||
'label': f'{under}/{len(summary)} categories under budget',
|
||||
'tip': tip,
|
||||
}
|
||||
|
||||
|
||||
def _score_goals(max_pts=25):
|
||||
"""
|
||||
Average completion % across active (non-completed) goals.
|
||||
100% avg → full marks; scales linearly.
|
||||
"""
|
||||
goals = Goal.query.filter_by(is_completed=False).all()
|
||||
if not goals:
|
||||
completed = Goal.query.filter_by(is_completed=True).count()
|
||||
return {
|
||||
'points': max_pts if completed else round(max_pts * 0.5),
|
||||
'max': max_pts,
|
||||
'value': 100.0 if completed else 0.0,
|
||||
'label': 'All goals completed!' if completed else 'No savings goals set',
|
||||
'tip': None if completed else 'Create a savings goal to track progress.',
|
||||
}
|
||||
|
||||
pcts = []
|
||||
for g in goals:
|
||||
target = float(g.target_amount)
|
||||
if target > 0:
|
||||
pcts.append(min(float(g.current_amount) / target * 100, 100))
|
||||
|
||||
avg = (sum(pcts) / len(pcts)) if pcts else 0
|
||||
pts = round(avg / 100 * max_pts)
|
||||
|
||||
behind = [g.name for g, p in zip(goals, pcts) if p < 25]
|
||||
tip = None
|
||||
if behind:
|
||||
tip = f'Behind on: {", ".join(behind[:2])}{"…" if len(behind) > 2 else ""}.'
|
||||
|
||||
return {
|
||||
'points': pts,
|
||||
'max': max_pts,
|
||||
'value': round(avg, 1),
|
||||
'label': f'{round(avg, 0):.0f}% avg goal progress ({len(goals)} active)',
|
||||
'tip': tip,
|
||||
}
|
||||
|
||||
|
||||
def _score_emergency_fund(max_pts=25):
|
||||
"""
|
||||
Liquid assets vs 3-month expense target.
|
||||
≥ 3 months → full marks; scales linearly up to 3 months.
|
||||
"""
|
||||
from app.services.goal_service import get_emergency_fund_status
|
||||
ef = get_emergency_fund_status()
|
||||
|
||||
liquid = ef['liquid_assets']
|
||||
target3 = ef['target_3mo']
|
||||
covered = ef['months_covered']
|
||||
pct3 = ef['pct_3mo'] # 0–100, capped
|
||||
|
||||
pts = round(pct3 / 100 * max_pts)
|
||||
|
||||
if covered >= 3:
|
||||
tip = None
|
||||
elif covered >= 1:
|
||||
short = target3 - liquid
|
||||
tip = f'Build to 3-month emergency fund (need ${short:,.0f} more).'
|
||||
else:
|
||||
tip = 'Start an emergency fund — aim for 1 month of expenses first.'
|
||||
|
||||
return {
|
||||
'points': pts,
|
||||
'max': max_pts,
|
||||
'value': round(covered, 1),
|
||||
'label': f'{covered:.1f} months emergency fund',
|
||||
'tip': tip,
|
||||
}
|
||||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
def compute_health_score():
|
||||
"""
|
||||
Compute the overall financial health score.
|
||||
|
||||
Returns:
|
||||
{
|
||||
score: int 0–100
|
||||
grade: str 'A' | 'B' | 'C' | 'D' | 'F'
|
||||
color: str CSS color
|
||||
components: list of component dicts
|
||||
}
|
||||
"""
|
||||
components = {
|
||||
'savings': _score_savings(),
|
||||
'budgets': _score_budgets(),
|
||||
'goals': _score_goals(),
|
||||
'emergency_fund': _score_emergency_fund(),
|
||||
}
|
||||
|
||||
score = sum(c['points'] for c in components.values())
|
||||
|
||||
if score >= 85:
|
||||
grade, color = 'A', '#10b981'
|
||||
elif score >= 70:
|
||||
grade, color = 'B', '#3b82f6'
|
||||
elif score >= 55:
|
||||
grade, color = 'C', '#f59e0b'
|
||||
elif score >= 40:
|
||||
grade, color = 'D', '#f97316'
|
||||
else:
|
||||
grade, color = 'F', '#ef4444'
|
||||
|
||||
# Add display names for the template
|
||||
labels = {
|
||||
'savings': 'Savings Rate',
|
||||
'budgets': 'Budget Adherence',
|
||||
'goals': 'Goal Progress',
|
||||
'emergency_fund': 'Emergency Fund',
|
||||
}
|
||||
icons = {
|
||||
'savings': 'bi-piggy-bank',
|
||||
'budgets': 'bi-pie-chart',
|
||||
'goals': 'bi-bullseye',
|
||||
'emergency_fund': 'bi-shield-check',
|
||||
}
|
||||
|
||||
component_list = [
|
||||
{
|
||||
'key': key,
|
||||
'name': labels[key],
|
||||
'icon': icons[key],
|
||||
'points': c['points'],
|
||||
'max': c['max'],
|
||||
'value': c['value'],
|
||||
'label': c['label'],
|
||||
'tip': c['tip'],
|
||||
'pct': round(c['points'] / c['max'] * 100),
|
||||
}
|
||||
for key, c in components.items()
|
||||
]
|
||||
|
||||
return {
|
||||
'score': score,
|
||||
'grade': grade,
|
||||
'color': color,
|
||||
'components': component_list,
|
||||
'tips': [c['tip'] for c in component_list if c['tip']],
|
||||
}
|
||||
@@ -323,6 +323,99 @@ def update_prices(investment_ids=None):
|
||||
return updated
|
||||
|
||||
|
||||
def check_and_save_price_alerts(threshold: float = 5.0) -> int:
|
||||
"""
|
||||
Fetch today's day-change for every unique ticker that has an active holding.
|
||||
For any ticker where |day_change_pct| >= threshold, write an AiInsight row
|
||||
with insight_type='alert' so the investments page can surface a banner.
|
||||
|
||||
Deduplicates by ticker so each ticker's Groq/Yahoo call happens only once.
|
||||
Returns the number of alerts saved.
|
||||
"""
|
||||
import json
|
||||
from app.models.ai_insight import AiInsight
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
investments = Investment.query.filter(
|
||||
Investment.ticker != None,
|
||||
Investment.ticker != '',
|
||||
Investment.is_active == True,
|
||||
).all()
|
||||
|
||||
if not investments:
|
||||
return 0
|
||||
|
||||
# Collect unique tickers and their holding names
|
||||
ticker_map = {} # ticker → asset_name (first one found)
|
||||
for inv in investments:
|
||||
t = inv.ticker.upper()
|
||||
if t not in ticker_map:
|
||||
ticker_map[t] = inv.asset_name
|
||||
|
||||
alerts = []
|
||||
for ticker, asset_name in ticker_map.items():
|
||||
try:
|
||||
change = fetch_day_change(ticker)
|
||||
except Exception:
|
||||
continue
|
||||
if not change:
|
||||
continue
|
||||
pct = change.get('day_change_pct') or 0
|
||||
if abs(pct) >= threshold:
|
||||
alerts.append({
|
||||
'ticker': ticker,
|
||||
'asset_name': asset_name,
|
||||
'day_change_pct': round(pct, 2),
|
||||
'current_price': change.get('current'),
|
||||
})
|
||||
|
||||
if not alerts:
|
||||
return 0
|
||||
|
||||
# Upsert: overwrite any earlier alert from today
|
||||
existing = AiInsight.query.filter_by(
|
||||
insight_date=today, insight_type='alert'
|
||||
).first()
|
||||
content_json = json.dumps(alerts)
|
||||
if existing:
|
||||
existing.content = content_json
|
||||
else:
|
||||
db.session.add(AiInsight(
|
||||
insight_date=today,
|
||||
insight_type='alert',
|
||||
content=content_json,
|
||||
prompt_summary=f'price_alert threshold={threshold}%',
|
||||
))
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
log.info('[investment] saved %d price alert(s) (threshold=%.1f%%)', len(alerts), threshold)
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log.error('[investment] failed to save price alerts: %s', e)
|
||||
|
||||
return len(alerts)
|
||||
|
||||
|
||||
def get_price_alerts():
|
||||
"""
|
||||
Return today's price alert list (from ai_insights) or [] if none exist.
|
||||
Each item: {ticker, asset_name, day_change_pct, current_price}
|
||||
"""
|
||||
import json
|
||||
from app.models.ai_insight import AiInsight
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
row = AiInsight.query.filter_by(insight_date=today, insight_type='alert').first()
|
||||
if not row:
|
||||
return []
|
||||
try:
|
||||
return json.loads(row.content)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_portfolio_summary():
|
||||
"""
|
||||
Return portfolio-level aggregates across all active investments.
|
||||
|
||||
@@ -217,12 +217,6 @@ def sync_transactions(item):
|
||||
return added, modified, removed, cursor
|
||||
|
||||
|
||||
def build_category_map():
|
||||
from app.models.category import Category
|
||||
cats = Category.query.filter_by(is_active=True).all()
|
||||
return {c.name: c.id for c in cats}
|
||||
|
||||
|
||||
def _map_plaid_category(plaid_cats, txn_type):
|
||||
"""Map Plaid category array to a PFM category name."""
|
||||
if not plaid_cats:
|
||||
@@ -292,6 +286,7 @@ def sync_preview(item):
|
||||
added, _modified, _removed, next_cursor = sync_transactions(item)
|
||||
|
||||
# Build maps
|
||||
from app.services.bank_import_service import build_category_map
|
||||
plaid_accounts = PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all()
|
||||
plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts}
|
||||
cat_map = build_category_map()
|
||||
@@ -324,9 +319,17 @@ def import_transactions(parsed_txns, next_cursor, item):
|
||||
affected_accounts = set()
|
||||
plaid_account_ids_synced = set()
|
||||
|
||||
# Batch duplicate check — one IN query instead of one LIKE per transaction
|
||||
candidate_notes = {p['notes'] for p in parsed_txns} # 'Plaid:{id}'
|
||||
existing_notes = {
|
||||
r[0] for r in
|
||||
db.session.query(Transaction.notes)
|
||||
.filter(Transaction.notes.in_(candidate_notes))
|
||||
.all()
|
||||
} if candidate_notes else set()
|
||||
|
||||
for p in parsed_txns:
|
||||
pid = p['plaid_id']
|
||||
if Transaction.query.filter(Transaction.notes.like(f'%Plaid:{pid}%')).first():
|
||||
if p['notes'] in existing_notes:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
@@ -344,18 +347,13 @@ def import_transactions(parsed_txns, next_cursor, item):
|
||||
plaid_account_ids_synced.add(p['plaid_account_id'])
|
||||
imported += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Advance cursor
|
||||
# Single commit — transactions + cursor + metadata together
|
||||
item.cursor = next_cursor
|
||||
item.last_synced_at = datetime.utcnow()
|
||||
|
||||
# Update last_sync_date per PlaidAccount
|
||||
today = date.today()
|
||||
for pa in PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all():
|
||||
if pa.plaid_account_id in plaid_account_ids_synced:
|
||||
pa.last_sync_date = today
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Refresh balances for mapped accounts
|
||||
@@ -441,6 +439,7 @@ def auto_sync_item(item):
|
||||
|
||||
added, modified, removed, next_cursor = sync_transactions(item)
|
||||
|
||||
from app.services.bank_import_service import build_category_map
|
||||
plaid_accounts = PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all()
|
||||
plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts}
|
||||
cat_map = build_category_map()
|
||||
@@ -456,15 +455,12 @@ def auto_sync_item(item):
|
||||
|
||||
imported, skipped = import_transactions(parsed, next_cursor, item)
|
||||
|
||||
# Remove transactions that Plaid says are gone (e.g. pending dropped)
|
||||
# Remove transactions that Plaid says are gone — batch lookup
|
||||
removed_count = 0
|
||||
if removed:
|
||||
for r in removed:
|
||||
tid = r.get('transaction_id', '')
|
||||
txn = Transaction.query.filter(
|
||||
Transaction.notes.like(f'%Plaid:{tid}%')
|
||||
).first()
|
||||
if txn:
|
||||
remove_notes = {f'Plaid:{r.get("transaction_id", "")}' for r in removed if r.get('transaction_id')}
|
||||
txns_to_delete = Transaction.query.filter(Transaction.notes.in_(remove_notes)).all()
|
||||
for txn in txns_to_delete:
|
||||
db.session.delete(txn)
|
||||
removed_count += 1
|
||||
if removed_count:
|
||||
|
||||
@@ -94,6 +94,104 @@ def process_due_rules(dry_run=False):
|
||||
return created
|
||||
|
||||
|
||||
def projected_cash_flow(days=90):
|
||||
"""
|
||||
Build a projected cash flow from all active recurring rules over the next
|
||||
N days. Returns weekly-bucketed chart data plus a flat event list.
|
||||
|
||||
Returns dict:
|
||||
labels — list of 'Mon DD' strings (week-start dates)
|
||||
income — list of floats (income per week bucket)
|
||||
expense — list of floats (expense per week bucket)
|
||||
balance — list of floats (running balance at end of each bucket)
|
||||
events — list of {date, description, amount, type, rule_id}
|
||||
starting_balance — float
|
||||
ending_balance — float
|
||||
total_income — float
|
||||
total_expense — float
|
||||
net — float
|
||||
"""
|
||||
from app.models.account import Account
|
||||
from sqlalchemy import func
|
||||
from app.extensions import db
|
||||
|
||||
today = date.today()
|
||||
cutoff = today + timedelta(days=days)
|
||||
|
||||
# Starting balance = sum of all active account balances
|
||||
starting_balance = float(
|
||||
db.session.query(func.coalesce(func.sum(Account.balance), 0))
|
||||
.filter(Account.is_active == True)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
# Enumerate all occurrences of active rules within the window
|
||||
rules = RecurringRule.query.filter_by(is_active=True).all()
|
||||
events = []
|
||||
for rule in rules:
|
||||
run_date = rule.next_run or rule.start_date
|
||||
# Advance to window start if rule fires before today
|
||||
while run_date < today:
|
||||
run_date = next_occurrence(run_date, rule.frequency)
|
||||
while run_date <= cutoff:
|
||||
if rule.end_date and run_date > rule.end_date:
|
||||
break
|
||||
events.append({
|
||||
'date': run_date,
|
||||
'description': rule.description,
|
||||
'amount': float(rule.amount),
|
||||
'type': rule.transaction_type,
|
||||
'rule_id': rule.id,
|
||||
})
|
||||
run_date = next_occurrence(run_date, rule.frequency)
|
||||
|
||||
events.sort(key=lambda e: e['date'])
|
||||
|
||||
# Build weekly buckets: each bucket starts on Monday
|
||||
# Find the Monday on or before today
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
buckets = []
|
||||
ws = week_start
|
||||
while ws <= cutoff:
|
||||
buckets.append(ws)
|
||||
ws += timedelta(weeks=1)
|
||||
|
||||
bucket_income = [0.0] * len(buckets)
|
||||
bucket_expense = [0.0] * len(buckets)
|
||||
|
||||
for ev in events:
|
||||
# Find which bucket this event falls in
|
||||
idx = (ev['date'] - week_start).days // 7
|
||||
if 0 <= idx < len(buckets):
|
||||
if ev['type'] == 'income':
|
||||
bucket_income[idx] += ev['amount']
|
||||
else:
|
||||
bucket_expense[idx] += ev['amount']
|
||||
|
||||
# Running balance
|
||||
running = starting_balance
|
||||
bucket_balance = []
|
||||
for inc, exp in zip(bucket_income, bucket_expense):
|
||||
running += inc - exp
|
||||
bucket_balance.append(round(running, 2))
|
||||
|
||||
total_income = sum(bucket_income)
|
||||
total_expense = sum(bucket_expense)
|
||||
|
||||
return {
|
||||
'labels': [b.strftime('%b %d') for b in buckets],
|
||||
'income': [round(v, 2) for v in bucket_income],
|
||||
'expense': [round(v, 2) for v in bucket_expense],
|
||||
'balance': bucket_balance,
|
||||
'events': events,
|
||||
'starting_balance': round(starting_balance, 2),
|
||||
'ending_balance': round(bucket_balance[-1], 2) if bucket_balance else round(starting_balance, 2),
|
||||
'total_income': round(total_income, 2),
|
||||
'total_expense': round(total_expense, 2),
|
||||
'net': round(total_income - total_expense, 2),
|
||||
}
|
||||
|
||||
|
||||
def get_upcoming(days=30):
|
||||
"""Return list of upcoming recurring transactions in the next N days."""
|
||||
today = date.today()
|
||||
|
||||
@@ -4,8 +4,9 @@ net worth history, category trends, and tax year reports.
|
||||
"""
|
||||
|
||||
import calendar
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import func
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
from sqlalchemy import func, extract
|
||||
from app.extensions import db
|
||||
from app.models.transaction import Transaction
|
||||
from app.models.category import Category
|
||||
@@ -172,18 +173,48 @@ def yearly_report(year):
|
||||
# ── Net worth history ─────────────────────────────────────────────────────────
|
||||
|
||||
def net_worth_history():
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
snapshots = NetWorthSnapshot.query\
|
||||
.order_by(NetWorthSnapshot.snapshot_date.asc())\
|
||||
.all()
|
||||
return {
|
||||
|
||||
result = {
|
||||
'snapshots': snapshots,
|
||||
'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots],
|
||||
'values': [float(s.net_worth) for s in snapshots],
|
||||
'assets': [float(s.total_assets) for s in snapshots],
|
||||
'liabilities': [float(s.total_liabilities) for s in snapshots],
|
||||
'count': len(snapshots),
|
||||
'proj_labels': [],
|
||||
'proj_values': [],
|
||||
'projected_1yr': None,
|
||||
'monthly_delta': None,
|
||||
}
|
||||
|
||||
if len(snapshots) >= 3:
|
||||
recent = snapshots[-6:] # up to last 6 data points
|
||||
deltas = [
|
||||
float(recent[i].net_worth) - float(recent[i - 1].net_worth)
|
||||
for i in range(1, len(recent))
|
||||
]
|
||||
avg_delta = sum(deltas) / len(deltas)
|
||||
|
||||
last_nw = float(snapshots[-1].net_worth)
|
||||
last_date = snapshots[-1].snapshot_date
|
||||
|
||||
proj_labels, proj_values = [], []
|
||||
for i in range(1, 13):
|
||||
proj_labels.append((last_date + relativedelta(months=i)).strftime('%b %Y'))
|
||||
proj_values.append(round(last_nw + avg_delta * i, 2))
|
||||
|
||||
result['proj_labels'] = proj_labels
|
||||
result['proj_values'] = proj_values
|
||||
result['projected_1yr'] = proj_values[-1]
|
||||
result['monthly_delta'] = round(avg_delta, 2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Category spending trends (last 6 months) ──────────────────────────────────
|
||||
|
||||
@@ -268,6 +299,160 @@ def tax_year_summary(year):
|
||||
}
|
||||
|
||||
|
||||
# ── Month-over-month category comparison ─────────────────────────────────────
|
||||
|
||||
def category_mom_comparison():
|
||||
"""
|
||||
Returns per-category expense totals for: this month, last month, and the
|
||||
3-month rolling average (last 3 complete months). Sorted by this-month
|
||||
spend descending.
|
||||
"""
|
||||
today = date.today()
|
||||
|
||||
this_start = today.replace(day=1)
|
||||
this_end = today
|
||||
|
||||
last_end = this_start - timedelta(days=1)
|
||||
last_start = last_end.replace(day=1)
|
||||
|
||||
# Build month ranges for the 3-month rolling average (the 3 complete months
|
||||
# ending with last month)
|
||||
avg_ranges = []
|
||||
cursor = last_start
|
||||
for _ in range(3):
|
||||
me = cursor - timedelta(days=1)
|
||||
ms = me.replace(day=1)
|
||||
avg_ranges.append((ms, me))
|
||||
cursor = ms
|
||||
|
||||
def _totals_by_cat(start, end):
|
||||
rows = db.session.query(
|
||||
Category.id,
|
||||
Category.name,
|
||||
Category.color,
|
||||
Category.icon,
|
||||
func.sum(Transaction.amount).label('total'),
|
||||
).join(Transaction, Transaction.category_id == Category.id)\
|
||||
.filter(
|
||||
Transaction.transaction_type == 'expense',
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
).group_by(Category.id).all()
|
||||
return {r.id: {'name': r.name, 'color': r.color, 'icon': r.icon,
|
||||
'total': float(r.total)} for r in rows}
|
||||
|
||||
this_data = _totals_by_cat(this_start, this_end)
|
||||
last_data = _totals_by_cat(last_start, last_end)
|
||||
avg_data = [_totals_by_cat(s, e) for s, e in avg_ranges]
|
||||
|
||||
# Collect all known category IDs + their metadata
|
||||
cat_meta = {}
|
||||
for src in [this_data, last_data] + avg_data:
|
||||
for cid, info in src.items():
|
||||
if cid not in cat_meta:
|
||||
cat_meta[cid] = {k: info[k] for k in ('name', 'color', 'icon')}
|
||||
|
||||
rows = []
|
||||
for cid, meta in cat_meta.items():
|
||||
this_amt = this_data.get(cid, {}).get('total', 0.0)
|
||||
last_amt = last_data.get(cid, {}).get('total', 0.0)
|
||||
avg_monthly = (
|
||||
sum(md.get(cid, {}).get('total', 0.0) for md in avg_data) / len(avg_data)
|
||||
if avg_data else 0.0
|
||||
)
|
||||
change_pct = (
|
||||
round((this_amt - last_amt) / last_amt * 100, 1)
|
||||
if last_amt > 0 else None
|
||||
)
|
||||
rows.append({
|
||||
**meta,
|
||||
'id': cid,
|
||||
'this_month': round(this_amt, 2),
|
||||
'last_month': round(last_amt, 2),
|
||||
'avg_3mo': round(avg_monthly, 2),
|
||||
'change_pct': change_pct,
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: r['this_month'], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
# ── Spending anomaly detection ────────────────────────────────────────────────
|
||||
|
||||
def spending_anomalies(days_back=30, multiplier=2.0, min_avg=10.0, min_amount=10.0):
|
||||
"""
|
||||
Find transactions in the last `days_back` days whose amount is more than
|
||||
`multiplier` × the category's average monthly spend over the prior 3 months.
|
||||
Returns a list of dicts with transaction details + context, capped at 5.
|
||||
"""
|
||||
today = date.today()
|
||||
window_start = today - timedelta(days=days_back)
|
||||
|
||||
# Baseline: the 3 complete months before today's month
|
||||
base_end = today.replace(day=1) - timedelta(days=1)
|
||||
base_start = (base_end.replace(day=1) - timedelta(days=60)).replace(day=1)
|
||||
|
||||
# Per-category, per-month totals over baseline
|
||||
rows = db.session.query(
|
||||
Transaction.category_id,
|
||||
extract('year', Transaction.date).label('yr'),
|
||||
extract('month', Transaction.date).label('mo'),
|
||||
func.sum(Transaction.amount).label('total'),
|
||||
).filter(
|
||||
Transaction.transaction_type == 'expense',
|
||||
Transaction.date >= base_start,
|
||||
Transaction.date <= base_end,
|
||||
Transaction.category_id.isnot(None),
|
||||
).group_by(
|
||||
Transaction.category_id,
|
||||
extract('year', Transaction.date),
|
||||
extract('month', Transaction.date),
|
||||
).all()
|
||||
|
||||
cat_month_totals = defaultdict(list)
|
||||
for r in rows:
|
||||
cat_month_totals[r.category_id].append(float(r.total))
|
||||
|
||||
cat_avg = {
|
||||
cid: sum(totals) / len(totals)
|
||||
for cid, totals in cat_month_totals.items()
|
||||
}
|
||||
|
||||
# Recent transactions in the window
|
||||
recent = (
|
||||
Transaction.query
|
||||
.filter(
|
||||
Transaction.transaction_type == 'expense',
|
||||
Transaction.date >= window_start,
|
||||
Transaction.date <= today,
|
||||
Transaction.category_id.isnot(None),
|
||||
)
|
||||
.order_by(Transaction.date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
anomalies = []
|
||||
for txn in recent:
|
||||
avg = cat_avg.get(txn.category_id)
|
||||
amt = float(txn.amount)
|
||||
if avg and avg >= min_avg and amt >= min_amount and amt > avg * multiplier:
|
||||
anomalies.append({
|
||||
'id': txn.id,
|
||||
'date': txn.date.strftime('%b %d'),
|
||||
'description': txn.description,
|
||||
'amount': amt,
|
||||
'category': txn.category.name if txn.category else 'Other',
|
||||
'category_color': txn.category.color if txn.category else '#94a3b8',
|
||||
'category_icon': txn.category.icon if txn.category else 'bi-tag',
|
||||
'avg': round(avg, 2),
|
||||
'multiple': round(amt / avg, 1),
|
||||
})
|
||||
|
||||
# Sort by multiple desc (biggest outliers first), cap at 5
|
||||
anomalies.sort(key=lambda a: a['multiple'], reverse=True)
|
||||
return anomalies[:5]
|
||||
|
||||
|
||||
# ── Snapshot helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def take_net_worth_snapshot():
|
||||
|
||||
@@ -336,12 +336,6 @@ def get_transactions(connection, account_hash, start_date, end_date):
|
||||
return data
|
||||
|
||||
|
||||
def build_category_map():
|
||||
from app.models.category import Category
|
||||
cats = Category.query.filter_by(is_active=True).all()
|
||||
return {c.name: c.id for c in cats}
|
||||
|
||||
|
||||
def parse_transaction(schwab_txn, pfm_account_id, cat_id_map):
|
||||
"""
|
||||
Convert a Schwab transaction dict to a PFM-ready dict.
|
||||
@@ -413,6 +407,7 @@ def sync_preview(schwab_account, days_back=90):
|
||||
end_date=today,
|
||||
)
|
||||
|
||||
from app.services.bank_import_service import build_category_map
|
||||
cat_map = build_category_map()
|
||||
return [
|
||||
parse_transaction(t, schwab_account.pfm_account_id, cat_map)
|
||||
@@ -428,14 +423,20 @@ def import_transactions(parsed_txns, schwab_account):
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.models.transaction import Transaction
|
||||
from app.services.account_service import calc_balance
|
||||
|
||||
imported = skipped = 0
|
||||
affected = set()
|
||||
|
||||
# Batch duplicate check — one IN query instead of one LIKE per transaction
|
||||
candidate_notes = {p['notes'] for p in parsed_txns} # 'Schwab:{id}'
|
||||
existing_notes = {
|
||||
r[0] for r in
|
||||
db.session.query(Transaction.notes)
|
||||
.filter(Transaction.notes.in_(candidate_notes))
|
||||
.all()
|
||||
} if candidate_notes else set()
|
||||
|
||||
for p in parsed_txns:
|
||||
sid = p['schwab_id']
|
||||
if Transaction.query.filter(Transaction.notes.like(f'%Schwab:{sid}%')).first():
|
||||
if p['notes'] in existing_notes:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
@@ -448,19 +449,15 @@ def import_transactions(parsed_txns, schwab_account):
|
||||
date = p['date'],
|
||||
notes = p['notes'],
|
||||
))
|
||||
if p['account_id']:
|
||||
affected.add(p['account_id'])
|
||||
imported += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Single commit — transactions + metadata together
|
||||
schwab_account.last_sync_date = date.today()
|
||||
schwab_account.connection.last_synced_at = datetime.utcnow()
|
||||
if parsed_txns:
|
||||
schwab_account.last_schwab_txn_id = parsed_txns[0]['schwab_id']
|
||||
db.session.commit()
|
||||
|
||||
for acct_id in affected:
|
||||
calc_balance(acct_id)
|
||||
# Balance for Schwab accounts is set by sync_account_snapshot (liquidationValue),
|
||||
# not by summing transactions — skip calc_balance here.
|
||||
|
||||
return imported, skipped
|
||||
|
||||
@@ -231,13 +231,6 @@ def parse_transaction(teller_txn, pfm_account_id, category_id_map, is_credit_car
|
||||
}
|
||||
|
||||
|
||||
def build_category_map():
|
||||
"""Build {pfm_category_name: category_id} from DB."""
|
||||
from app.models.category import Category
|
||||
cats = Category.query.filter_by(is_active=True).all()
|
||||
return {c.name: c.id for c in cats}
|
||||
|
||||
|
||||
def sync_preview(teller_account, days_back=90):
|
||||
"""
|
||||
Fetch transactions for a TellerAccount and return a preview list.
|
||||
@@ -272,6 +265,7 @@ def sync_preview(teller_account, days_back=90):
|
||||
)
|
||||
raise
|
||||
|
||||
from app.services.bank_import_service import build_category_map
|
||||
cat_map = build_category_map()
|
||||
is_cc = (
|
||||
teller_account.account_type == 'credit' or
|
||||
@@ -302,37 +296,38 @@ def import_transactions(parsed_txns, teller_account):
|
||||
skipped = 0
|
||||
affected_accounts = set()
|
||||
|
||||
# Batch duplicate check — one IN query instead of one LIKE per transaction
|
||||
candidate_notes = {f'Teller:{p["teller_id"]}' for p in parsed_txns}
|
||||
existing_notes = {
|
||||
r[0] for r in
|
||||
db.session.query(Transaction.notes)
|
||||
.filter(Transaction.notes.in_(candidate_notes))
|
||||
.all()
|
||||
}
|
||||
|
||||
for p in parsed_txns:
|
||||
# Duplicate check: match on teller_id in notes
|
||||
teller_id = p['teller_id']
|
||||
existing = Transaction.query.filter(
|
||||
Transaction.notes.like(f'%{teller_id}%')
|
||||
).first()
|
||||
if existing:
|
||||
note_str = f'Teller:{p["teller_id"]}'
|
||||
if note_str in existing_notes:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
txn = Transaction(
|
||||
db.session.add(Transaction(
|
||||
account_id = p['account_id'],
|
||||
category_id = p.get('category_id'),
|
||||
transaction_type = p['transaction_type'],
|
||||
amount = p['amount'],
|
||||
description = p['description'],
|
||||
date = p['date'],
|
||||
notes=f"Teller:{teller_id}",
|
||||
)
|
||||
db.session.add(txn)
|
||||
notes = note_str,
|
||||
))
|
||||
if p['account_id']:
|
||||
affected_accounts.add(p['account_id'])
|
||||
imported += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Update sync metadata on both the account and its parent enrollment
|
||||
# Single commit — transactions + sync metadata together
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow()
|
||||
teller_account.last_sync_date = date.today()
|
||||
teller_account.enrollment.last_synced_at = now
|
||||
teller_account.enrollment.last_synced_at = datetime.utcnow()
|
||||
if parsed_txns:
|
||||
teller_account.last_teller_txn_id = parsed_txns[0]['teller_id']
|
||||
db.session.commit()
|
||||
|
||||
@@ -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}'))
|
||||
@@ -197,6 +197,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Import progress bar (shown only during chunked import) #}
|
||||
<div id="import-progress-wrap" class="d-none mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;color:var(--muted);">
|
||||
<span id="import-progress-label">Importing…</span>
|
||||
<span id="import-progress-pct" class="mono fw-bold">0%</span>
|
||||
</div>
|
||||
<div class="progress" style="height:6px;border-radius:3px;">
|
||||
<div id="import-progress-bar" class="progress-bar bg-success" role="progressbar" style="width:0%;transition:width .3s ease;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Parse errors (non-fatal) #}
|
||||
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
|
||||
<strong>Warnings</strong> — these rows were skipped:<br>
|
||||
@@ -580,31 +591,56 @@
|
||||
setImportLoading(true);
|
||||
$('import-error').classList.add('d-none');
|
||||
|
||||
fetch('{{ url_for("bank_import.confirm_import") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': CSRF,
|
||||
},
|
||||
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setImportLoading(false);
|
||||
if (data.error) {
|
||||
$('import-error').textContent = data.error;
|
||||
$('import-error').classList.remove('d-none');
|
||||
return;
|
||||
const CHUNK = 50;
|
||||
const useChunks = rows.length > CHUNK;
|
||||
let totalImported = 0, totalSkipped = 0;
|
||||
|
||||
function setProgress(done, total) {
|
||||
const pct = Math.round(done / total * 100);
|
||||
$('import-progress-bar').style.width = pct + '%';
|
||||
$('import-progress-pct').textContent = pct + '%';
|
||||
$('import-progress-label').textContent = `Importing… ${done} of ${total}`;
|
||||
}
|
||||
|
||||
async function sendChunks() {
|
||||
if (useChunks) {
|
||||
$('import-progress-wrap').classList.remove('d-none');
|
||||
setProgress(0, rows.length);
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for (let i = 0; i < rows.length; i += CHUNK) chunks.push(rows.slice(i, i + CHUNK));
|
||||
let sent = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const r = await fetch('{{ url_for("bank_import.confirm_import") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
|
||||
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows: chunk }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
totalImported += data.imported;
|
||||
totalSkipped += data.skipped;
|
||||
sent += chunk.length;
|
||||
if (useChunks) setProgress(sent, rows.length);
|
||||
}
|
||||
}
|
||||
|
||||
sendChunks()
|
||||
.then(() => {
|
||||
setImportLoading(false);
|
||||
$('import-progress-wrap').classList.add('d-none');
|
||||
$('done-title').textContent =
|
||||
data.imported + ' transaction' + (data.imported !== 1 ? 's' : '') + ' imported successfully';
|
||||
totalImported + ' transaction' + (totalImported !== 1 ? 's' : '') + ' imported successfully';
|
||||
$('done-subtitle').textContent =
|
||||
data.skipped > 0 ? data.skipped + ' duplicate(s) skipped.' : 'All transactions were new.';
|
||||
totalSkipped > 0 ? totalSkipped + ' duplicate(s) skipped.' : 'All transactions were new.';
|
||||
showStep('step-done');
|
||||
})
|
||||
.catch(err => {
|
||||
setImportLoading(false);
|
||||
$('import-error').textContent = 'Request failed: ' + err;
|
||||
$('import-progress-wrap').classList.add('d-none');
|
||||
$('import-error').textContent = 'Import failed: ' + err;
|
||||
$('import-error').classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
+232
-16
@@ -169,7 +169,10 @@
|
||||
.table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
/* Remove h-100 height constraint on table wrappers so overflow-x works */
|
||||
.pcard.p-0.h-100 { height: auto !important; }
|
||||
.pfm-table { min-width: 560px; }
|
||||
/* Default min-width — hides .d-mob-none columns first, then scroll */
|
||||
.pfm-table { min-width: 420px; }
|
||||
/* Wider tables (investments, etc.) can opt in to more space */
|
||||
.pfm-table.wide { min-width: 700px; }
|
||||
/* Hide low-priority columns */
|
||||
.d-mob-none { display: none !important; }
|
||||
/* Topbar title: truncate so action buttons always fit */
|
||||
@@ -186,13 +189,103 @@
|
||||
.btn-label { display: none; }
|
||||
.tb-right .btn { padding-left: 8px; padding-right: 8px; }
|
||||
.stat-card .stat-value { font-size: 16px; }
|
||||
/* AI chat: shorter on small screens so it doesn't eat the whole viewport */
|
||||
#chatMessages { height: 300px !important; }
|
||||
/* Projection / combo charts: cap height so they're not giant on phones */
|
||||
.proj-chart-wrap { height: 200px !important; }
|
||||
}
|
||||
/* table-wrap inside a regular (padded) pcard also needs overflow-x */
|
||||
.pcard .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
/* Keyboard shortcut cheatsheet modal */
|
||||
#kbd-modal .kbd-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
#kbd-modal .kbd-row:last-child { border: none; }
|
||||
#kbd-modal kbd { background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 4px; padding: 2px 7px; font-size: 12px; font-family: 'DM Mono', monospace; }
|
||||
.sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; }
|
||||
.sb-overlay.on { display: block; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────────────────────────── */
|
||||
body.dark-mode {
|
||||
--body-bg: #0f172a; --card-bg: #1e293b; --text: #e2e8f0;
|
||||
--muted: #94a3b8; --border: #334155;
|
||||
color-scheme: dark;
|
||||
}
|
||||
body.dark-mode #topbar { background: #1e293b; border-color: #334155; }
|
||||
body.dark-mode .pfm-table tbody tr:hover { background: #263548; }
|
||||
body.dark-mode .pfm-table th, body.dark-mode .pfm-table td { border-color: #334155; }
|
||||
body.dark-mode .form-control, body.dark-mode .form-select {
|
||||
background: #0f172a; border-color: #334155; color: #e2e8f0;
|
||||
}
|
||||
body.dark-mode .form-control:focus, body.dark-mode .form-select:focus {
|
||||
background: #0f172a; border-color: #3b82f6; color: #e2e8f0;
|
||||
box-shadow: 0 0 0 .2rem rgba(59,130,246,.25);
|
||||
}
|
||||
body.dark-mode .form-control::placeholder { color: #475569; }
|
||||
body.dark-mode .form-check-input { background-color: #334155; border-color: #475569; }
|
||||
body.dark-mode .form-check-input:checked { background-color: #3b82f6; border-color: #3b82f6; }
|
||||
body.dark-mode .btn-outline-secondary { color: #94a3b8; border-color: #334155; }
|
||||
body.dark-mode .btn-outline-secondary:hover,
|
||||
body.dark-mode .btn-outline-secondary:focus { background: #334155; color: #e2e8f0; border-color: #334155; }
|
||||
body.dark-mode .btn-outline-primary { color: #93c5fd; border-color: #1e40af; }
|
||||
body.dark-mode .btn-outline-primary:hover { background: #1e40af; color: #fff; }
|
||||
body.dark-mode .btn-outline-warning { color: #fcd34d; border-color: #92400e; }
|
||||
body.dark-mode .btn-outline-warning:hover { background: #92400e; color: #fff; }
|
||||
body.dark-mode .btn-outline-danger { color: #fca5a5; border-color: #991b1b; }
|
||||
body.dark-mode .btn-outline-danger:hover { background: #991b1b; color: #fff; }
|
||||
body.dark-mode .dropdown-menu { background: #1e293b; border-color: #334155; }
|
||||
body.dark-mode .dropdown-item { color: #e2e8f0; }
|
||||
body.dark-mode .dropdown-item:hover, body.dark-mode .dropdown-item:focus { background: #334155; color: #f1f5f9; }
|
||||
body.dark-mode .dropdown-divider { border-color: #334155; }
|
||||
body.dark-mode .modal-content { background: #1e293b; border-color: #334155; color: #e2e8f0; }
|
||||
body.dark-mode .modal-header, body.dark-mode .modal-footer { border-color: #334155; }
|
||||
body.dark-mode .modal-header .btn-close { filter: invert(1) grayscale(1); }
|
||||
body.dark-mode .alert-warning { background: #451a03; border-color: #92400e; color: #fcd34d; }
|
||||
body.dark-mode .alert-danger { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
|
||||
body.dark-mode .alert-success { background: #052e16; border-color: #166534; color: #86efac; }
|
||||
body.dark-mode .alert-info { background: #0c1a2e; border-color: #1e40af; color: #93c5fd; }
|
||||
body.dark-mode .progress { background: #334155; }
|
||||
body.dark-mode .table { color: #e2e8f0; }
|
||||
body.dark-mode .input-group-text { background: #334155; border-color: #334155; color: #94a3b8; }
|
||||
body.dark-mode .badge-income { background: #064e3b; color: #6ee7b7; }
|
||||
body.dark-mode .badge-expense { background: #450a0a; color: #fca5a5; }
|
||||
body.dark-mode .badge-transfer { background: #1e3a5f; color: #93c5fd; }
|
||||
body.dark-mode #kbd-modal kbd { background: #334155; border-color: #475569; color: #e2e8f0; }
|
||||
body.dark-mode .report-tab { background: #1e293b; border-color: #334155; color: #94a3b8; }
|
||||
body.dark-mode .report-tab:hover { background: #334155; color: #e2e8f0; }
|
||||
body.dark-mode .report-tab.active { background: #e2e8f0; color: #0f172a; border-color: #e2e8f0; }
|
||||
/* Override common hardcoded light backgrounds in component inline styles */
|
||||
body.dark-mode [style*="background:#f8fafc"], body.dark-mode [style*="background: #f8fafc"],
|
||||
body.dark-mode [style*="background:#f1f5f9"], body.dark-mode [style*="background: #f1f5f9"]
|
||||
{ background: #263548 !important; }
|
||||
body.dark-mode [style*="background:#eff6ff"], body.dark-mode [style*="background: #eff6ff"]
|
||||
{ background: #1e3a5f !important; }
|
||||
body.dark-mode [style*="background:#fef2f2"], body.dark-mode [style*="background: #fef2f2"]
|
||||
{ background: #450a0a !important; }
|
||||
body.dark-mode [style*="background:#f0fdf4"], body.dark-mode [style*="background: #f0fdf4"]
|
||||
{ background: #052e16 !important; }
|
||||
body.dark-mode [style*="background:#fff5e6"], body.dark-mode [style*="background:#fffbeb"]
|
||||
{ background: #451a03 !important; }
|
||||
/* Text color overrides for hardcoded darks */
|
||||
body.dark-mode [style*="color:#0f172a"] { color: #e2e8f0 !important; }
|
||||
body.dark-mode [style*="color:#1e293b"] { color: #94a3b8 !important; }
|
||||
body.dark-mode [style*="color:#374151"] { color: #94a3b8 !important; }
|
||||
/* Border overrides */
|
||||
body.dark-mode [style*="border-bottom:1px solid #e2e8f0"],
|
||||
body.dark-mode [style*="border-bottom: 1px solid #e2e8f0"] { border-color: #334155 !important; }
|
||||
body.dark-mode [style*="border:1px solid #e2e8f0"],
|
||||
body.dark-mode [style*="border: 1px solid #e2e8f0"] { border-color: #334155 !important; }
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</style>
|
||||
<script>
|
||||
/* Apply dark mode class to body before first paint to avoid flash */
|
||||
(function(){ if(localStorage.getItem('pfm_dark')==='1') document.documentElement.setAttribute('data-pfm-dark','1'); })();
|
||||
</script>
|
||||
<style>
|
||||
/* Instant pre-body dark (avoids FOUC) — matches body.dark-mode vars */
|
||||
html[data-pfm-dark="1"] body { --body-bg:#0f172a; --card-bg:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --border:#334155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sb-overlay" id="sbOverlay"></div>
|
||||
@@ -217,21 +310,11 @@
|
||||
class="sb-link {% if request.blueprint == 'transactions' %}active{% endif %}"
|
||||
>
|
||||
<i class="bi bi-arrow-left-right"></i
|
||||
><span class="lt">Transactions</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('transactions.new', type='income') }}"
|
||||
class="sb-link"
|
||||
>
|
||||
<i class="bi bi-arrow-down-circle"></i
|
||||
><span class="lt">Add Income</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('transactions.new', type='expense') }}"
|
||||
class="sb-link"
|
||||
>
|
||||
<i class="bi bi-arrow-up-circle"></i
|
||||
><span class="lt">Add Expense</span>
|
||||
><span class="lt">Transactions
|
||||
{% if plaid_review_count > 0 %}
|
||||
<span style="margin-left:6px;background:#7c3aed;color:#fff;font-size:10px;font-weight:700;padding:1px 5px;border-radius:10px;line-height:1.4;">{{ plaid_review_count }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('accounts.index') }}"
|
||||
@@ -239,6 +322,17 @@
|
||||
>
|
||||
<i class="bi bi-wallet2"></i><span class="lt">Accounts</span>
|
||||
</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
|
||||
href="{{ url_for('bank_import.index') }}"
|
||||
class="sb-link {% if request.blueprint == 'bank_import' %}active{% endif %}"
|
||||
@@ -311,6 +405,17 @@
|
||||
<span class="tb-title">{% block page_title %}{% endblock %}</span>
|
||||
<div class="tb-right">
|
||||
{% block topbar_actions %}{% endblock %}
|
||||
<button id="dark-toggle" type="button" title="Toggle dark mode"
|
||||
style="background:none;border:none;color:var(--muted);font-size:15px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
|
||||
class="d-inline-flex align-items-center">
|
||||
<i class="bi bi-moon-stars"></i>
|
||||
</button>
|
||||
<button type="button" data-bs-toggle="modal" data-bs-target="#kbd-modal"
|
||||
title="Keyboard shortcuts (?)"
|
||||
style="background:none;border:none;color:var(--muted);font-size:13px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
|
||||
class="d-none d-md-inline-flex align-items-center">
|
||||
<i class="bi bi-keyboard"></i>
|
||||
</button>
|
||||
<span class="d-none d-sm-inline small text-muted mono"
|
||||
>{{ current_user.display_name or current_user.username }}</span
|
||||
>
|
||||
@@ -339,6 +444,27 @@
|
||||
<!-- MAIN -->
|
||||
<main id="main">{% block content %}{% endblock %}</main>
|
||||
|
||||
<!-- Keyboard shortcuts cheatsheet modal -->
|
||||
<div class="modal fade" id="kbd-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2 px-3">
|
||||
<h6 class="modal-title mb-0"><i class="bi bi-keyboard me-2"></i>Keyboard Shortcuts</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body px-3 py-2">
|
||||
<div class="kbd-row"><span>New expense</span><kbd>n</kbd></div>
|
||||
<div class="kbd-row"><span>New income</span><kbd>i</kbd></div>
|
||||
<div class="kbd-row"><span>Focus search</span><kbd>/</kbd></div>
|
||||
<div class="kbd-row"><span>Go to dashboard</span><kbd>g</kbd> then <kbd>h</kbd></div>
|
||||
<div class="kbd-row"><span>Go to transactions</span><kbd>g</kbd> then <kbd>t</kbd></div>
|
||||
<div class="kbd-row"><span>Go to accounts</span><kbd>g</kbd> then <kbd>a</kbd></div>
|
||||
<div class="kbd-row"><span>Show this help</span><kbd>?</kbd></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -372,6 +498,96 @@
|
||||
}, 4500);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Keyboard shortcuts ────────────────────────────────────────────────
|
||||
(function () {
|
||||
var gPending = false, gTimer = null;
|
||||
|
||||
function inInput() {
|
||||
var t = document.activeElement;
|
||||
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||||
t.tagName === 'SELECT' || t.isContentEditable);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (inInput()) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
var key = e.key;
|
||||
|
||||
// ? → show shortcut modal
|
||||
if (key === '?') {
|
||||
e.preventDefault();
|
||||
bootstrap.Modal.getOrCreateInstance(
|
||||
document.getElementById('kbd-modal')
|
||||
).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// g-chord for navigation
|
||||
if (gPending) {
|
||||
clearTimeout(gTimer);
|
||||
gPending = false;
|
||||
if (key === 'h') { window.location.href = '/'; return; }
|
||||
if (key === 't') { window.location.href = '{{ url_for("transactions.index") }}'; return; }
|
||||
if (key === 'a') { window.location.href = '{{ url_for("accounts.index") }}'; return; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'g') {
|
||||
gPending = true;
|
||||
gTimer = setTimeout(function () { gPending = false; }, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// n → new expense
|
||||
if (key === 'n') {
|
||||
e.preventDefault();
|
||||
window.location.href = '{{ url_for("transactions.new", type="expense") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
// i → new income
|
||||
if (key === 'i') {
|
||||
e.preventDefault();
|
||||
window.location.href = '{{ url_for("transactions.new", type="income") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
// / → focus search input
|
||||
if (key === '/') {
|
||||
var s = document.querySelector('input[name="q"], input[type="search"], .search-input');
|
||||
if (s) {
|
||||
e.preventDefault();
|
||||
s.focus();
|
||||
s.select();
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Dark mode toggle ─────────────────────────────────────────────────
|
||||
(function () {
|
||||
var DKEY = 'pfm_dark';
|
||||
var body = document.body;
|
||||
var btn = document.getElementById('dark-toggle');
|
||||
|
||||
function applyDark(on) {
|
||||
body.classList.toggle('dark-mode', on);
|
||||
document.documentElement.setAttribute('data-pfm-dark', on ? '1' : '0');
|
||||
if (btn) btn.querySelector('i').className = on ? 'bi bi-sun' : 'bi bi-moon-stars';
|
||||
}
|
||||
|
||||
// Initialise from storage
|
||||
var saved = localStorage.getItem(DKEY) === '1';
|
||||
applyDark(saved);
|
||||
|
||||
if (btn) btn.addEventListener('click', function () {
|
||||
var next = !body.classList.contains('dark-mode');
|
||||
localStorage.setItem(DKEY, next ? '1' : '0');
|
||||
applyDark(next);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -58,6 +58,17 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Budget vs Actual chart -->
|
||||
{% set chart_cats = summary | selectattr('has_budget') | list %}
|
||||
{% if chart_cats %}
|
||||
<div class="pcard mb-4">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">Budget vs Actual</div>
|
||||
<div style="position:relative;height:{{ [[chart_cats|length * 42, 180]|max, 380]|min }}px;">
|
||||
<canvas id="budgetChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Budget Items -->
|
||||
{% if summary %}
|
||||
<div class="pcard p-0">
|
||||
@@ -148,3 +159,74 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% set chart_cats = summary | selectattr('has_budget') | list %}
|
||||
{% if chart_cats %}
|
||||
<script>
|
||||
(function() {
|
||||
const labels = {{ chart_cats | map(attribute='category') | map(attribute='name') | list | tojson }};
|
||||
const spent = {{ chart_cats | map(attribute='spent') | list | tojson }};
|
||||
const limits = {{ chart_cats | map(attribute='limit') | list | tojson }};
|
||||
const colors = {{ chart_cats | map(attribute='category') | map(attribute='color') | list | tojson }};
|
||||
|
||||
const ctx = document.getElementById('budgetChart');
|
||||
if (!ctx) return;
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Spent',
|
||||
data: spent,
|
||||
backgroundColor: colors.map((c, i) => {
|
||||
const pct = limits[i] > 0 ? spent[i] / limits[i] : 0;
|
||||
return pct >= 1 ? '#ef4444cc' : pct >= 0.8 ? '#f59e0bcc' : '#10b981cc';
|
||||
}),
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.55,
|
||||
categoryPercentage: 0.9,
|
||||
},
|
||||
{
|
||||
label: 'Budget',
|
||||
data: limits,
|
||||
backgroundColor: '#e2e8f0cc',
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.55,
|
||||
categoryPercentage: 0.9,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { boxWidth: 10, font: { size: 11 } } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const sym = '{{ current_user.currency_symbol }}';
|
||||
return ` ${ctx.dataset.label}: ${sym}${ctx.parsed.x.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2})}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#f1f5f9' },
|
||||
ticks: {
|
||||
font: { size: 10 },
|
||||
callback: v => '{{ current_user.currency_symbol }}' + v.toLocaleString(),
|
||||
},
|
||||
},
|
||||
y: { ticks: { font: { size: 11 } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -138,6 +138,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Health Score -->
|
||||
<div id="health-score-card" class="pcard mb-4" style="display:none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<span class="pcard-title mb-0">Financial Health Score</span>
|
||||
<span id="hs-grade-badge" class="badge fw-bold" style="font-size:13px;padding:4px 12px;"></span>
|
||||
</div>
|
||||
<div class="row g-3 align-items-center">
|
||||
<!-- Score ring -->
|
||||
<div class="col-12 col-sm-auto d-flex justify-content-center">
|
||||
<div style="position:relative;width:96px;height:96px;">
|
||||
<svg viewBox="0 0 36 36" style="width:96px;height:96px;transform:rotate(-90deg);">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--border)" stroke-width="3"/>
|
||||
<circle id="hs-ring" cx="18" cy="18" r="15.9155" fill="none" stroke="#10b981" stroke-width="3"
|
||||
stroke-dasharray="0 100" stroke-linecap="round"
|
||||
style="transition:stroke-dasharray .8s ease, stroke .4s;"/>
|
||||
</svg>
|
||||
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">
|
||||
<div id="hs-score" class="mono fw-bold" style="font-size:22px;line-height:1;">—</div>
|
||||
<div style="font-size:10px;color:var(--muted);">/ 100</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Component bars -->
|
||||
<div class="col">
|
||||
<div id="hs-components" class="d-flex flex-column gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tips -->
|
||||
<div id="hs-tips" class="mt-3" style="display:none;">
|
||||
<div class="d-flex flex-wrap gap-2" id="hs-tips-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-xl-8">
|
||||
@@ -238,6 +271,15 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Spending Anomalies -->
|
||||
<div id="anomaly-card" class="pcard mb-4" style="display:none;">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle" style="color:#f59e0b;font-size:15px;"></i>
|
||||
<span class="pcard-title mb-0">Unusual Spending Detected</span>
|
||||
</div>
|
||||
<div id="anomaly-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Accounts + Recent Transactions -->
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-4">
|
||||
@@ -287,9 +329,10 @@
|
||||
<a href="{{ url_for('transactions.index') }}" style="font-size:12px;color:#3b82f6;">View all</a>
|
||||
</div>
|
||||
{% if recent_txns %}
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table">
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Description</th><th>Category</th><th class="text-end">Amount</th></tr>
|
||||
<tr><th>Date</th><th>Description</th><th class="d-mob-none">Category</th><th class="text-end">Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for txn in recent_txns %}
|
||||
@@ -299,7 +342,7 @@
|
||||
<div style="font-size:13px;font-weight:500;">{{ txn.description }}</div>
|
||||
<div style="font-size:11px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="d-mob-none">
|
||||
{% if txn.category %}
|
||||
<span style="font-size:12px;"><i class="bi {{ txn.category.icon }}" style="color:{{ txn.category.color }};"></i> {{ txn.category.name }}</span>
|
||||
{% else %}<span class="text-muted" style="font-size:12px;">—</span>{% endif %}
|
||||
@@ -311,6 +354,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted small">No transactions yet. <a href="{{ url_for('transactions.new', type='expense') }}">Add one</a>.</p>
|
||||
{% endif %}
|
||||
@@ -318,6 +362,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Bills -->
|
||||
{% if upcoming_bills %}
|
||||
<div class="pcard mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="pcard-title mb-0">Upcoming Bills <span style="font-size:11px;font-weight:400;color:var(--muted);">(next 14 days)</span></span>
|
||||
<a href="{{ url_for('settings.recurring') }}" style="font-size:12px;color:#3b82f6;">Manage →</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left:16px;">Rule</th>
|
||||
<th class="d-mob-none">Frequency</th>
|
||||
<th>Due</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end d-mob-none" style="padding-right:16px;">Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in upcoming_bills %}
|
||||
<tr>
|
||||
<td style="padding-left:16px;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if rule.category %}
|
||||
<div style="width:26px;height:26px;border-radius:6px;background:{{ rule.category.color }}22;color:{{ rule.category.color }};display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0;">
|
||||
<i class="bi {{ rule.category.icon }}"></i>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="width:26px;height:26px;border-radius:6px;background:#f1f5f9;color:#94a3b8;display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0;">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span style="font-size:13px;font-weight:500;">{{ rule.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ rule.frequency | title }}</td>
|
||||
<td>
|
||||
{% set days_until = (rule.next_run - today).days %}
|
||||
<span style="font-size:12px;" class="{% if days_until == 0 %}text-expense fw-semibold{% elif days_until <= 3 %}text-warning{% endif %}">
|
||||
{% if days_until == 0 %}Today
|
||||
{% elif days_until == 1 %}Tomorrow
|
||||
{% else %}{{ rule.next_run.strftime('%b %d') }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end mono {% if rule.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;">
|
||||
{% if rule.transaction_type=='income' %}+{% else %}-{% endif %}{{ rule.amount | currency }}
|
||||
</td>
|
||||
<td class="text-end d-mob-none" style="font-size:12px;color:var(--muted);padding-right:16px;">{{ rule.account.name if rule.account else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Custom Range Modal -->
|
||||
<div class="modal fade" id="customModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
@@ -345,6 +445,50 @@
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
// ── Period memory (localStorage) ─────────────────────────────────────────────
|
||||
(function () {
|
||||
var KEY_P = 'pfm_dash_period', KEY_Q = 'pfm_dash_params';
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
|
||||
// If landing with no period param, restore last saved period
|
||||
if (!params.has('period') && !params.has('date_from')) {
|
||||
var saved = localStorage.getItem(KEY_Q);
|
||||
if (saved) {
|
||||
window.location.replace('/?' + saved);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save current period whenever a period button is clicked
|
||||
document.querySelectorAll('a.period-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var url = new URL(this.href, location.origin);
|
||||
var p = url.searchParams.get('period');
|
||||
if (p) {
|
||||
localStorage.setItem(KEY_P, p);
|
||||
localStorage.setItem(KEY_Q, url.search.slice(1));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save when custom range form is submitted
|
||||
var customForm = document.querySelector('#customModal form');
|
||||
if (customForm) {
|
||||
customForm.addEventListener('submit', function () {
|
||||
var fd = new FormData(this);
|
||||
localStorage.setItem(KEY_P, 'custom');
|
||||
localStorage.setItem(KEY_Q, new URLSearchParams(fd).toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Save the current period (handles direct URL visits with ?period=xxx)
|
||||
var cur = params.get('period');
|
||||
if (cur) {
|
||||
localStorage.setItem(KEY_P, cur);
|
||||
localStorage.setItem(KEY_Q, params.toString());
|
||||
}
|
||||
})();
|
||||
|
||||
(function(){
|
||||
const ctx = document.getElementById('cashflowChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
@@ -500,6 +644,102 @@ function refreshFxRate() {
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Financial health score ───────────────────────────────────────────────────
|
||||
(function(){
|
||||
var card = document.getElementById('health-score-card');
|
||||
var ring = document.getElementById('hs-ring');
|
||||
var scoreEl = document.getElementById('hs-score');
|
||||
var gradeEl = document.getElementById('hs-grade-badge');
|
||||
var compsEl = document.getElementById('hs-components');
|
||||
var tipsWrap = document.getElementById('hs-tips');
|
||||
var tipsList = document.getElementById('hs-tips-list');
|
||||
if (!card) return;
|
||||
|
||||
const SYM = '{{ current_user.currency_symbol }}';
|
||||
|
||||
fetch('/api/health-score')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) return;
|
||||
card.style.display = '';
|
||||
|
||||
// Score ring
|
||||
var circ = 100;
|
||||
ring.style.strokeDasharray = (data.score / 100 * circ) + ' ' + circ;
|
||||
ring.style.stroke = data.color;
|
||||
scoreEl.textContent = data.score;
|
||||
scoreEl.style.color = data.color;
|
||||
|
||||
// Grade badge
|
||||
gradeEl.textContent = 'Grade ' + data.grade;
|
||||
gradeEl.style.background = data.color + '22';
|
||||
gradeEl.style.color = data.color;
|
||||
|
||||
// Component bars
|
||||
var icons = {
|
||||
savings: 'bi-piggy-bank',
|
||||
budgets: 'bi-pie-chart',
|
||||
goals: 'bi-bullseye',
|
||||
emergency_fund: 'bi-shield-check',
|
||||
};
|
||||
compsEl.innerHTML = data.components.map(c => `
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;">
|
||||
<span><i class="bi ${c.icon} me-1" style="color:var(--muted);"></i>${c.name}</span>
|
||||
<span class="mono" style="color:var(--muted);">${c.points}/${c.max}</span>
|
||||
</div>
|
||||
<div class="progress" style="height:5px;border-radius:3px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width:${c.pct}%;background:${c.pct>=80?'#10b981':c.pct>=50?'#3b82f6':c.pct>=25?'#f59e0b':'#ef4444'};transition:width .6s ease;">
|
||||
</div>
|
||||
</div>
|
||||
${c.label ? `<div style="font-size:10px;color:var(--muted);margin-top:1px;">${c.label}</div>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
// Tips
|
||||
if (data.tips && data.tips.length) {
|
||||
tipsWrap.style.display = '';
|
||||
tipsList.innerHTML = data.tips.map(t =>
|
||||
`<span style="font-size:11px;background:#fef3c7;color:#92400e;border-radius:20px;padding:3px 10px;display:inline-block;">
|
||||
<i class="bi bi-lightbulb me-1"></i>${t}
|
||||
</span>`
|
||||
).join('');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
|
||||
// ── Spending anomalies ───────────────────────────────────────────────────────
|
||||
(function(){
|
||||
const card = document.getElementById('anomaly-card');
|
||||
const list = document.getElementById('anomaly-list');
|
||||
if (!card || !list) return;
|
||||
const SYM = '{{ current_user.currency_symbol }}';
|
||||
function fmt(n){ return SYM + Math.abs(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}); }
|
||||
fetch('/api/anomalies')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const items = data.anomalies || [];
|
||||
if (!items.length) return; // stay hidden
|
||||
list.innerHTML = items.map(a => `
|
||||
<div class="d-flex justify-content-between align-items-center py-2" style="border-bottom:1px solid var(--border);font-size:13px;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0;"></span>
|
||||
<div>
|
||||
<div class="fw-medium">${a.description}</div>
|
||||
<div class="text-muted" style="font-size:11px;">${a.category} · ${a.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end flex-shrink-0 ms-3">
|
||||
<div class="mono text-expense">${fmt(a.amount)}</div>
|
||||
<div style="font-size:11px;color:#f59e0b;">${a.multiple.toFixed(1)}× avg ${fmt(a.avg_amount)}</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
card.style.display = '';
|
||||
})
|
||||
.catch(() => {}); // silently ignore on error
|
||||
})();
|
||||
|
||||
function toggleFxChart(){
|
||||
const wrap = document.getElementById('fxChartWrap');
|
||||
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
{% if completed_goals %}
|
||||
<div class="pcard">
|
||||
<div class="pcard-title mb-3">Completed Goals 🎉</div>
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table">
|
||||
<thead><tr><th>Goal</th><th>Target</th><th>Completed</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -138,5 +139,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -78,6 +78,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Price alerts banner (populated by AJAX) -->
|
||||
<div id="price-alert-banner" style="display:none;background:#fef3c7;border:1px solid #fcd34d;color:#78350f;font-size:13px;border-radius:8px;padding:10px 14px;" class="d-flex align-items-start gap-2 mb-3">
|
||||
<i class="bi bi-graph-up-arrow flex-shrink-0 mt-1" style="color:#d97706;"></i>
|
||||
<div class="flex-grow-1" id="price-alert-text"></div>
|
||||
<button type="button" onclick="document.getElementById('price-alert-banner').style.display='none';"
|
||||
style="background:none;border:none;color:#92400e;cursor:pointer;font-size:16px;line-height:1;padding:0 4px;" title="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
@@ -134,7 +142,8 @@
|
||||
|
||||
{# ── Reusable holdings table macro ──────────────────────────────────────── #}
|
||||
{% macro holdings_table(inv_list) %}
|
||||
<table class="pfm-table">
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left:20px;">Asset</th>
|
||||
@@ -219,6 +228,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Chart + Allocation -->
|
||||
@@ -515,6 +525,27 @@
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
// ── Price alert banner ────────────────────────────────────────────────────────
|
||||
(function () {
|
||||
fetch('{{ url_for("investments.api_price_alerts") }}')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data || !data.alerts || !data.alerts.length) return;
|
||||
const sym = '{{ current_user.currency_symbol or "$" }}';
|
||||
const parts = data.alerts.map(a => {
|
||||
const sign = a.day_change_pct >= 0 ? '+' : '';
|
||||
const color = a.day_change_pct >= 0 ? '#065f46' : '#991b1b';
|
||||
return `<strong style="color:${color};">${a.ticker} (${sign}${a.day_change_pct}%)</strong>`;
|
||||
});
|
||||
const banner = document.getElementById('price-alert-banner');
|
||||
const textEl = document.getElementById('price-alert-text');
|
||||
const label = data.alerts.length === 1 ? 'holding moved' : 'holdings moved';
|
||||
textEl.innerHTML = `<strong>${data.alerts.length} ${label} ≥5% today:</strong> ${parts.join(', ')}`;
|
||||
banner.style.display = '';
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -134,12 +134,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Month-over-month comparison (monthly report only) -->
|
||||
{% if report_type == 'monthly' and mom_data %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="pcard">
|
||||
<div class="pcard-title mb-3">Month-over-Month Comparison</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm" style="font-size:13px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid var(--border);">
|
||||
<th>Category</th>
|
||||
<th class="text-end">This Month</th>
|
||||
<th class="text-end">Last Month</th>
|
||||
<th class="text-end d-none d-md-table-cell">3-Mo Avg</th>
|
||||
<th class="text-end">Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in mom_data %}
|
||||
<tr>
|
||||
<td>
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ row.color }};margin-right:6px;"></span>
|
||||
<i class="bi {{ row.icon }}" style="color:{{ row.color }};"></i> {{ row.name }}
|
||||
</td>
|
||||
<td class="text-end mono">{{ row.this_month | currency }}</td>
|
||||
<td class="text-end mono text-muted">{{ row.last_month | currency }}</td>
|
||||
<td class="text-end mono text-muted d-none d-md-table-cell">{{ row.avg_3mo | currency }}</td>
|
||||
<td class="text-end">
|
||||
{% if row.change_pct is none %}
|
||||
<span class="text-muted">—</span>
|
||||
{% elif row.change_pct > 0 %}
|
||||
<span class="badge text-expense" style="background:#ef444415;font-size:11px;">+{{ row.change_pct | round(0) | int }}%</span>
|
||||
{% elif row.change_pct < 0 %}
|
||||
<span class="badge text-income" style="background:#10b98115;font-size:11px;">{{ row.change_pct | round(0) | int }}%</span>
|
||||
{% else %}
|
||||
<span class="text-muted" style="font-size:11px;">0%</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Net worth history -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="pcard">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="pcard-title mb-0">Net Worth History</span>
|
||||
{% if nw_history.projected_1yr is defined and nw_history.projected_1yr %}
|
||||
<span class="badge" style="background:#3b82f615;color:#3b82f6;font-size:11px;font-weight:500;">
|
||||
Proj. 1yr: {{ nw_history.projected_1yr | currency }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="POST" action="{{ url_for('reports.manual_snapshot') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
@@ -257,13 +312,28 @@ const fmtCur = v => sym + Math.abs(v).toLocaleString(undefined, {minimumFraction
|
||||
{% if nw_history.count > 1 %}
|
||||
(function(){
|
||||
const ctx = document.getElementById('nwChart').getContext('2d');
|
||||
const histLabels = {{ nw_history.labels | tojson }};
|
||||
const histValues = {{ nw_history['values'] | tojson }};
|
||||
{% if nw_history.proj_labels is defined and nw_history.proj_labels %}
|
||||
const projLabels = {{ nw_history.proj_labels | tojson }};
|
||||
const projValues = {{ nw_history.proj_values | tojson }};
|
||||
// Stitch: last actual point is first projected point
|
||||
const allLabels = histLabels.concat(projLabels);
|
||||
const histPad = new Array(projLabels.length).fill(null);
|
||||
const projPad = new Array(histLabels.length - 1).fill(null);
|
||||
{% else %}
|
||||
const allLabels = histLabels;
|
||||
{% endif %}
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ nw_history.labels | tojson }},
|
||||
labels: allLabels,
|
||||
datasets: [
|
||||
{ label:'Net Worth', data: {{ nw_history['values'] | tojson }}, borderColor:'#3b82f6', backgroundColor:'#3b82f611', borderWidth:2, pointRadius:3, tension:.3, fill:true },
|
||||
{ label:'Assets', data: {{ nw_history.assets | tojson }}, borderColor:'#10b981', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[4,3] },
|
||||
{ label:'Net Worth', data: {% if nw_history.proj_labels is defined and nw_history.proj_labels %}histValues.concat(histPad){% else %}histValues{% endif %}, borderColor:'#3b82f6', backgroundColor:'#3b82f611', borderWidth:2, pointRadius:3, tension:.3, fill:true },
|
||||
{ label:'Assets', data: {% if nw_history.proj_labels is defined and nw_history.proj_labels %}{{ nw_history.assets | tojson }}.concat(histPad){% else %}{{ nw_history.assets | tojson }}{% endif %}, borderColor:'#10b981', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[4,3] },
|
||||
{% if nw_history.proj_labels is defined and nw_history.proj_labels %}
|
||||
{ label:'Projected', data: projPad.concat([histValues[histValues.length-1]]).concat(projValues), borderColor:'#3b82f6', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[6,4], backgroundColor:'transparent' },
|
||||
{% endif %}
|
||||
]
|
||||
},
|
||||
options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:10 } } } } }
|
||||
|
||||
@@ -43,6 +43,36 @@
|
||||
<small class="text-muted" style="font-size:11px;">AI model used for chat and daily insights.</small>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:var(--border);">
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-label fw-medium" style="font-size:13px;">Budget Alerts</div>
|
||||
<div class="form-check">
|
||||
{{ form.budget_alerts_enabled(class="form-check-input") }}
|
||||
{{ form.budget_alerts_enabled.label(class="form-check-label", style="font-size:13px;") }}
|
||||
</div>
|
||||
{% if smtp_ok %}
|
||||
<small class="text-muted" style="font-size:11px;">
|
||||
<i class="bi bi-check-circle-fill text-success me-1"></i>SMTP configured — alerts will go to <strong>{{ alert_email }}</strong>
|
||||
</small>
|
||||
{% else %}
|
||||
<small class="text-danger" style="font-size:11px;">
|
||||
<i class="bi bi-exclamation-circle me-1"></i>SMTP not configured — set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, ALERT_EMAIL in .env to enable emails.
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if smtp_ok %}
|
||||
<div class="mb-4">
|
||||
<form method="POST" action="{{ url_for('settings.test_email') }}" style="display:inline;">
|
||||
{{ form.hidden_tag() }}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
|
||||
<i class="bi bi-envelope me-1"></i>Send Test Email
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
<a href="{{ url_for('settings.password') }}" class="btn btn-outline-secondary ms-2">Change Password</a>
|
||||
</form>
|
||||
|
||||
@@ -94,4 +94,145 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cash Flow Projection -->
|
||||
<div class="pcard mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<span class="pcard-title mb-0">Projected Cash Flow</span>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm proj-btn btn-primary" data-days="30" style="font-size:12px;">30d</button>
|
||||
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="60" style="font-size:12px;">60d</button>
|
||||
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="90" style="font-size:12px;">90d</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="row g-2 mb-3" id="proj-stats">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="pcard pcard-sm text-center p-2" style="background:#f0fdf4;">
|
||||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Income</div>
|
||||
<div id="proj-income" class="mono text-income fw-bold" style="font-size:15px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="pcard pcard-sm text-center p-2" style="background:#fef2f2;">
|
||||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Expenses</div>
|
||||
<div id="proj-expense" class="mono text-expense fw-bold" style="font-size:15px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="pcard pcard-sm text-center p-2">
|
||||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Net</div>
|
||||
<div id="proj-net" class="mono fw-bold" style="font-size:15px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="pcard pcard-sm text-center p-2" style="background:#eff6ff;">
|
||||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">End Balance</div>
|
||||
<div id="proj-end" class="mono text-invest fw-bold" style="font-size:15px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div id="proj-chart-wrap" class="proj-chart-wrap" style="position:relative;height:200px;">
|
||||
<canvas id="projChart"></canvas>
|
||||
</div>
|
||||
<div id="proj-loading" class="text-center py-4 text-muted" style="display:none;">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Loading…
|
||||
</div>
|
||||
|
||||
<!-- Event list -->
|
||||
<div id="proj-events" class="mt-3" style="max-height:260px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
const SYM = '{{ current_user.currency_symbol }}';
|
||||
const fmtC = v => SYM + Math.abs(v).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:0});
|
||||
|
||||
let projChart = null;
|
||||
|
||||
function loadProjection(days) {
|
||||
// update active button
|
||||
document.querySelectorAll('.proj-btn').forEach(b => {
|
||||
const active = b.dataset.days == days;
|
||||
b.className = 'btn btn-sm proj-btn ' + (active ? 'btn-primary' : 'btn-outline-secondary');
|
||||
b.style.fontSize = '12px';
|
||||
});
|
||||
|
||||
document.getElementById('proj-loading').style.display = '';
|
||||
document.getElementById('proj-chart-wrap').style.display = 'none';
|
||||
|
||||
fetch('/settings/recurring/projection?days=' + days)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('proj-loading').style.display = 'none';
|
||||
document.getElementById('proj-chart-wrap').style.display = '';
|
||||
|
||||
// Summary stats
|
||||
document.getElementById('proj-income').textContent = fmtC(data.total_income);
|
||||
document.getElementById('proj-expense').textContent = fmtC(data.total_expense);
|
||||
const net = data.net;
|
||||
const netEl = document.getElementById('proj-net');
|
||||
netEl.textContent = (net >= 0 ? '+' : '-') + fmtC(Math.abs(net));
|
||||
netEl.style.color = net >= 0 ? '#10b981' : '#ef4444';
|
||||
document.getElementById('proj-end').textContent = fmtC(data.ending_balance);
|
||||
|
||||
// Chart
|
||||
if (projChart) projChart.destroy();
|
||||
const ctx = document.getElementById('projChart').getContext('2d');
|
||||
projChart = new Chart(ctx, {
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{ type:'bar', label:'Income', data:data.income, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
|
||||
{ type:'bar', label:'Expense', data:data.expense, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
|
||||
{ type:'line', label:'Balance', data:data.balance, borderColor:'#3b82f6', backgroundColor:'transparent', borderWidth:2, pointRadius:3, tension:.3, yAxisID:'y2' },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive:true, maintainAspectRatio:false,
|
||||
interaction:{ mode:'index', intersect:false },
|
||||
plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 }, boxWidth:10 } } },
|
||||
scales:{
|
||||
y: { position:'left', grid:{ color:'#f1f5f9' }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
|
||||
y2: { position:'right', grid:{ display:false }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
|
||||
x: { grid:{ display:false }, ticks:{ font:{size:10} } },
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Event list
|
||||
const evList = document.getElementById('proj-events');
|
||||
if (!data.events.length) {
|
||||
evList.innerHTML = '<p class="text-muted small">No recurring transactions in this period.</p>';
|
||||
return;
|
||||
}
|
||||
evList.innerHTML = data.events.map(ev => `
|
||||
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);font-size:13px;">
|
||||
<div>
|
||||
<span class="fw-medium">${ev.description}</span>
|
||||
<span class="text-muted ms-2" style="font-size:11px;">${ev.date}</span>
|
||||
</div>
|
||||
<span class="mono ${ev.type === 'income' ? 'text-income' : 'text-expense'}" style="font-size:13px;white-space:nowrap;">
|
||||
${ev.type === 'income' ? '+' : '-'}${SYM}${Math.abs(ev.amount).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}
|
||||
</span>
|
||||
</div>`).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('proj-loading').style.display = 'none';
|
||||
document.getElementById('proj-chart-wrap').style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.proj-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => loadProjection(parseInt(btn.dataset.days)));
|
||||
});
|
||||
|
||||
// Load 30-day projection on page load
|
||||
loadProjection(30);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -120,6 +120,13 @@
|
||||
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes", id="field_notes") }}
|
||||
</div>
|
||||
|
||||
<!-- Duplicate warning -->
|
||||
<div id="dupe-banner" style="display:none;background:#fef9c3;border:1px solid #fcd34d;border-radius:8px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#854d0e;align-items:center;gap-8px;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2" style="color:#d97706;"></i>
|
||||
<span id="dupe-text"></span>
|
||||
<button type="button" onclick="document.getElementById('dupe-banner').style.display='none';" style="background:none;border:none;font-size:14px;color:#854d0e;cursor:pointer;padding:0;margin-left:auto;">×</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" id="submitBtn"
|
||||
class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
|
||||
@@ -426,5 +433,39 @@ function setTxnType(type) {
|
||||
sel.value = prevVal;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duplicate detection ───────────────────────────────────────────────────────
|
||||
{% if not txn %}
|
||||
const DUPE_EXCLUDE_ID = '';
|
||||
{% else %}
|
||||
const DUPE_EXCLUDE_ID = '{{ txn.id }}';
|
||||
{% endif %}
|
||||
|
||||
let _dupeTimer = null;
|
||||
function checkDuplicate() {
|
||||
const amt = document.getElementById('field_amount').value;
|
||||
const dt = document.getElementById('field_date').value;
|
||||
const typ = document.querySelector('[name=transaction_type]').value;
|
||||
const banner = document.getElementById('dupe-banner');
|
||||
if (!amt || !dt || parseFloat(amt) <= 0) { banner.style.display = 'none'; return; }
|
||||
clearTimeout(_dupeTimer);
|
||||
_dupeTimer = setTimeout(() => {
|
||||
const url = `/transactions/api/check-duplicate?date=${dt}&amount=${amt}&type=${typ}${DUPE_EXCLUDE_ID ? '&exclude_id=' + DUPE_EXCLUDE_ID : ''}`;
|
||||
fetch(url).then(r => r.json()).then(data => {
|
||||
if (data.duplicates && data.duplicates.length > 0) {
|
||||
const list = data.duplicates.map(d => `"${d.description}" on ${d.date}${d.account ? ' (' + d.account + ')' : ''}`).join('; ');
|
||||
document.getElementById('dupe-text').textContent = `Possible duplicate: ${list}`;
|
||||
banner.style.display = 'flex';
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 600);
|
||||
}
|
||||
|
||||
['field_amount', 'field_date'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', checkDuplicate);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if plaid_review_count > 0 %}
|
||||
<div class="alert d-flex align-items-center gap-2 mb-3" style="background:#f5f3ff;border:1px solid #ddd6fe;color:#5b21b6;font-size:13px;border-radius:8px;padding:10px 14px;">
|
||||
<i class="bi bi-cloud-download flex-shrink-0"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{{ plaid_review_count }} Plaid transaction{{ 's' if plaid_review_count != 1 else '' }}</strong>
|
||||
imported via webhook without a category.
|
||||
<a href="{{ url_for('transactions.index') }}" style="color:#7c3aed;font-weight:600;" class="ms-1">Review & categorize →</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Tabs -->
|
||||
<div class="d-flex gap-1 mb-3">
|
||||
<a href="{{ url_for('transactions.index', tab='expense', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
|
||||
@@ -43,13 +53,14 @@
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="pcard pcard-sm mb-3">
|
||||
<form method="GET" action="{{ url_for('transactions.index') }}" class="row g-2 align-items-end">
|
||||
<form id="filter-form" method="GET" action="{{ url_for('transactions.index') }}" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="tab" value="{{ tab }}">
|
||||
<!-- Row 1: main filters -->
|
||||
<div class="col-12 col-md-3">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search description…" value="{{ search }}">
|
||||
<input type="text" name="q" id="f-q" class="form-control form-control-sm" placeholder="Search description & notes…" value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<select name="category_id" class="form-select form-select-sm">
|
||||
<select name="category_id" id="f-cat" class="form-select form-select-sm">
|
||||
<option value="">All categories</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if category_id == cat.id|string %}selected{% endif %}>{{ cat.name }}</option>
|
||||
@@ -57,7 +68,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<select name="account_id" class="form-select form-select-sm">
|
||||
<select name="account_id" id="f-acct" class="form-select form-select-sm">
|
||||
<option value="">All accounts</option>
|
||||
{% for acct in accounts %}
|
||||
<option value="{{ acct.id }}" {% if account_id == acct.id|string %}selected{% endif %}>{{ acct.name }}</option>
|
||||
@@ -65,14 +76,43 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from }}" placeholder="From">
|
||||
<input type="date" name="date_from" id="f-df" class="form-control form-control-sm" value="{{ date_from }}" placeholder="From">
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to }}" placeholder="To">
|
||||
<input type="date" name="date_to" id="f-dt" class="form-control form-control-sm" value="{{ date_to }}" placeholder="To">
|
||||
</div>
|
||||
<div class="col-12 col-md-1 d-flex gap-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary flex-grow-1"><i class="bi bi-search"></i></button>
|
||||
<a href="{{ url_for('transactions.index', tab=tab) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-x-lg"></i></a>
|
||||
<a href="{{ url_for('transactions.index', tab=tab) }}" class="btn btn-sm btn-outline-secondary" title="Clear filters"><i class="bi bi-x-lg"></i></a>
|
||||
</div>
|
||||
<!-- Row 2: amount range + preset controls -->
|
||||
<div class="col-6 col-md-2">
|
||||
<input type="number" name="amount_min" id="f-amin" class="form-control form-control-sm" placeholder="Min amount" min="0" step="0.01" value="{{ amount_min }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<input type="number" name="amount_max" id="f-amax" class="form-control form-control-sm" placeholder="Max amount" min="0" step="0.01" value="{{ amount_max }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-8 d-flex align-items-center gap-2 flex-wrap">
|
||||
<a href="{{ url_for('transactions.export_csv', tab=tab, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max) }}"
|
||||
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export filtered view as CSV">
|
||||
<i class="bi bi-filetype-csv me-1"></i>CSV
|
||||
</a>
|
||||
<a href="{{ url_for('transactions.export_excel', tab=tab, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max) }}"
|
||||
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export filtered view as Excel">
|
||||
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Excel
|
||||
</a>
|
||||
<span style="font-size:11px;color:var(--muted);">Saved filters:</span>
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" style="font-size:12px;" id="preset-dropdown" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-bookmark me-1"></i><span id="preset-label">Load preset</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" id="preset-menu" style="font-size:13px;min-width:200px;">
|
||||
<li><span class="dropdown-item text-muted" id="no-presets-msg">No saved presets</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" style="font-size:12px;" id="save-preset-btn" title="Save current filter as preset">
|
||||
<i class="bi bi-bookmark-plus me-1"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -153,6 +193,7 @@
|
||||
</td>
|
||||
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
|
||||
<a href="{{ url_for('transactions.edit', id=txn.id, next=request.full_path) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 8px;">Edit</a>
|
||||
<a href="{{ url_for('transactions.split', id=txn.id) }}" class="btn btn-sm btn-outline-secondary ms-1" style="font-size:11px;padding:2px 8px;" title="Split into multiple categories"><i class="bi bi-scissors"></i></a>
|
||||
<form method="POST" action="{{ url_for('transactions.delete', id=txn.id) }}" style="display:inline;" onsubmit="return confirm('Delete this transaction?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 8px;">Del</button>
|
||||
@@ -356,5 +397,89 @@ document.getElementById('bulk-cat-btn')?.addEventListener('click', () => {
|
||||
})
|
||||
.catch(() => alert('Network error — please try again.'));
|
||||
});
|
||||
|
||||
/* ── Saved filter presets (localStorage) ── */
|
||||
(function () {
|
||||
const PKEY = 'pfm_txn_presets';
|
||||
|
||||
function loadPresets() {
|
||||
try { return JSON.parse(localStorage.getItem(PKEY) || '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function savePresets(list) {
|
||||
localStorage.setItem(PKEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
function readForm() {
|
||||
return {
|
||||
q: document.getElementById('f-q')?.value || '',
|
||||
category_id:document.getElementById('f-cat')?.value || '',
|
||||
account_id: document.getElementById('f-acct')?.value || '',
|
||||
date_from: document.getElementById('f-df')?.value || '',
|
||||
date_to: document.getElementById('f-dt')?.value || '',
|
||||
amount_min: document.getElementById('f-amin')?.value || '',
|
||||
amount_max: document.getElementById('f-amax')?.value || '',
|
||||
tab: '{{ tab }}',
|
||||
};
|
||||
}
|
||||
|
||||
function applyPreset(p) {
|
||||
const base = '/transactions/?' + new URLSearchParams(p).toString();
|
||||
window.location.href = base;
|
||||
}
|
||||
|
||||
function renderMenu() {
|
||||
const presets = loadPresets();
|
||||
const menu = document.getElementById('preset-menu');
|
||||
const noMsg = document.getElementById('no-presets-msg');
|
||||
// remove old preset items (keep no-presets-msg li)
|
||||
menu.querySelectorAll('.preset-item').forEach(el => el.remove());
|
||||
|
||||
if (!presets.length) {
|
||||
noMsg.style.display = '';
|
||||
return;
|
||||
}
|
||||
noMsg.style.display = 'none';
|
||||
|
||||
presets.forEach((p, idx) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'preset-item d-flex align-items-center px-2 gap-1';
|
||||
li.innerHTML = `
|
||||
<button type="button" class="dropdown-item py-1 flex-grow-1 text-start" style="font-size:13px;">${p.name}</button>
|
||||
<button type="button" class="btn btn-sm p-0 text-danger preset-del" title="Delete" data-idx="${idx}" style="font-size:13px;line-height:1;background:none;border:none;">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>`;
|
||||
li.querySelector('.dropdown-item').addEventListener('click', () => applyPreset(p.filters));
|
||||
li.querySelector('.preset-del').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const list = loadPresets();
|
||||
list.splice(idx, 1);
|
||||
savePresets(list);
|
||||
renderMenu();
|
||||
});
|
||||
menu.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('save-preset-btn')?.addEventListener('click', () => {
|
||||
const name = prompt('Name this filter preset:');
|
||||
if (!name?.trim()) return;
|
||||
const list = loadPresets();
|
||||
list.push({ name: name.trim(), filters: readForm() });
|
||||
savePresets(list);
|
||||
renderMenu();
|
||||
// Flash the save button
|
||||
const btn = document.getElementById('save-preset-btn');
|
||||
btn.innerHTML = '<i class="bi bi-bookmark-check-fill me-1"></i>Saved!';
|
||||
btn.classList.replace('btn-outline-secondary', 'btn-success');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '<i class="bi bi-bookmark-plus me-1"></i>Save';
|
||||
btn.classList.replace('btn-success', 'btn-outline-secondary');
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
renderMenu();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Split Transaction{% endblock %}
|
||||
{% block page_title %}Split Transaction{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('transactions.index', tab=txn.transaction_type) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.split-row { display:grid; grid-template-columns:1fr 2fr 110px 36px; gap:8px; align-items:center; }
|
||||
@media (max-width:575px) { .split-row { grid-template-columns:1fr 1fr 90px 28px; } }
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-8 col-lg-7">
|
||||
|
||||
<!-- Original transaction summary -->
|
||||
<div class="pcard mb-3" style="border-left:4px solid {% if txn.transaction_type=='income' %}var(--income){% else %}var(--expense){% endif %};">
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:4px;">Original transaction</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:14px;">{{ txn.description }}</div>
|
||||
<div style="font-size:12px;color:var(--muted);">{{ txn.date.strftime('%b %d, %Y') }} · {{ txn.account.name if txn.account else '—' }}</div>
|
||||
</div>
|
||||
<div class="mono {% if txn.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:18px;font-weight:700;">
|
||||
{{ txn.amount | currency }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split form -->
|
||||
<div class="pcard">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0" style="font-size:14px;font-weight:600;">Split into parts</h6>
|
||||
<div style="font-size:12px;color:var(--muted);">
|
||||
Remaining: <span id="remaining" class="mono fw-bold">{{ txn.amount | currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" id="splitForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Column headers -->
|
||||
<div class="split-row mb-2" style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">
|
||||
<span>Category</span>
|
||||
<span>Description</span>
|
||||
<span>Amount</span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<div id="split-rows">
|
||||
<!-- Two default rows -->
|
||||
<div class="split-row mb-2 split-entry">
|
||||
<select name="split_category" class="form-select form-select-sm">
|
||||
<option value="">— None —</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}"{% if txn.category_id == cat.id %} selected{% endif %}>{{ cat.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="split_description" class="form-control form-control-sm" placeholder="{{ txn.description }}" value="{{ txn.description }}">
|
||||
<input type="number" name="split_amount" class="form-control form-control-sm split-amt" placeholder="0.00" step="0.01" min="0.01" oninput="updateRemaining()">
|
||||
<button type="button" onclick="removeRow(this)" class="btn btn-sm btn-outline-danger" style="padding:2px 8px;"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="split-row mb-2 split-entry">
|
||||
<select name="split_category" class="form-select form-select-sm">
|
||||
<option value="">— None —</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}">{{ cat.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="split_description" class="form-control form-control-sm" placeholder="{{ txn.description }}" value="{{ txn.description }}">
|
||||
<input type="number" name="split_amount" class="form-control form-control-sm split-amt" placeholder="0.00" step="0.01" min="0.01" oninput="updateRemaining()">
|
||||
<button type="button" onclick="removeRow(this)" class="btn btn-sm btn-outline-danger" style="padding:2px 8px;"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="addRow()" class="btn btn-sm btn-outline-secondary mb-3" style="font-size:12px;">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add row
|
||||
</button>
|
||||
|
||||
<!-- Total mismatch warning -->
|
||||
<div id="total-warn" style="display:none;background:#fee2e2;border:1px solid #fca5a5;border-radius:6px;padding:8px 12px;font-size:12px;color:#991b1b;margin-bottom:12px;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
Split total must equal <strong>{{ txn.amount | currency }}</strong>.
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" id="splitBtn" class="btn btn-primary" style="font-size:13px;">
|
||||
<i class="bi bi-scissors me-1"></i>Confirm Split
|
||||
</button>
|
||||
<a href="{{ url_for('transactions.index', tab=txn.transaction_type) }}" class="btn btn-outline-secondary" style="font-size:13px;">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
const ORIGINAL = {{ txn.amount | float }};
|
||||
const CSRF = '{{ csrf_token() }}';
|
||||
|
||||
const catOptions = `{% for cat in categories %}<option value="{{ cat.id }}">{{ cat.name }}</option>{% endfor %}`;
|
||||
const defaultDesc = {{ txn.description | tojson }};
|
||||
|
||||
function updateRemaining() {
|
||||
const amts = [...document.querySelectorAll('.split-amt')].map(i => parseFloat(i.value) || 0);
|
||||
const total = amts.reduce((a, b) => a + b, 0);
|
||||
const rem = ORIGINAL - total;
|
||||
const el = document.getElementById('remaining');
|
||||
el.textContent = (rem < 0 ? '-' : '') + Math.abs(rem).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||
el.style.color = Math.abs(rem) < 0.005 ? '#10b981' : (rem < 0 ? '#ef4444' : 'var(--text)');
|
||||
document.getElementById('total-warn').style.display = Math.abs(total - ORIGINAL) > 0.005 && total > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const container = document.getElementById('split-rows');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'split-row mb-2 split-entry';
|
||||
div.innerHTML = `
|
||||
<select name="split_category" class="form-select form-select-sm">
|
||||
<option value="">— None —</option>${catOptions}
|
||||
</select>
|
||||
<input type="text" name="split_description" class="form-control form-control-sm" placeholder="${defaultDesc}" value="${defaultDesc}">
|
||||
<input type="number" name="split_amount" class="form-control form-control-sm split-amt" placeholder="0.00" step="0.01" min="0.01" oninput="updateRemaining()">
|
||||
<button type="button" onclick="removeRow(this)" class="btn btn-sm btn-outline-danger" style="padding:2px 8px;"><i class="bi bi-x-lg"></i></button>`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
function removeRow(btn) {
|
||||
const rows = document.querySelectorAll('.split-entry');
|
||||
if (rows.length <= 2) { alert('Keep at least 2 rows.'); return; }
|
||||
btn.closest('.split-entry').remove();
|
||||
updateRemaining();
|
||||
}
|
||||
|
||||
document.getElementById('splitForm').addEventListener('submit', function(e) {
|
||||
const amts = [...document.querySelectorAll('.split-amt')].map(i => parseFloat(i.value) || 0);
|
||||
const total = amts.reduce((a, b) => a + b, 0);
|
||||
if (Math.abs(total - ORIGINAL) > 0.005) {
|
||||
e.preventDefault();
|
||||
document.getElementById('total-warn').style.display = 'block';
|
||||
document.getElementById('total-warn').scrollIntoView({behavior:'smooth', block:'nearest'});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% 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.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,53 @@
|
||||
"""
|
||||
Migration: add budget alert columns.
|
||||
|
||||
Run once after deploying the budget alerts feature:
|
||||
python scripts/add_budget_alert_columns.py
|
||||
|
||||
Adds to `budgets`:
|
||||
alert_sent_80 BOOLEAN NOT NULL DEFAULT FALSE
|
||||
alert_sent_100 BOOLEAN NOT NULL DEFAULT FALSE
|
||||
|
||||
Adds to `users`:
|
||||
budget_alerts_enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||
"""
|
||||
|
||||
import sys
|
||||
import 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()
|
||||
|
||||
with app.app_context():
|
||||
with db.engine.connect() as conn:
|
||||
# budgets table
|
||||
for col, default in [('alert_sent_80', '0'), ('alert_sent_100', '0')]:
|
||||
try:
|
||||
conn.execute(db.text(
|
||||
f"ALTER TABLE budgets ADD COLUMN {col} TINYINT(1) NOT NULL DEFAULT {default}"
|
||||
))
|
||||
print(f"Added budgets.{col}")
|
||||
except Exception as e:
|
||||
if 'Duplicate column' in str(e) or '1060' in str(e):
|
||||
print(f"budgets.{col} already exists — skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
# users table
|
||||
try:
|
||||
conn.execute(db.text(
|
||||
"ALTER TABLE users ADD COLUMN budget_alerts_enabled TINYINT(1) NOT NULL DEFAULT 0"
|
||||
))
|
||||
print("Added users.budget_alerts_enabled")
|
||||
except Exception as e:
|
||||
if 'Duplicate column' in str(e) or '1060' in str(e):
|
||||
print("users.budget_alerts_enabled already exists — skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
conn.commit()
|
||||
|
||||
print("Done.")
|
||||
@@ -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.')
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Daily cron safety-net: check all budgets for the current month and send any
|
||||
unsent 80% / 100% alert emails.
|
||||
|
||||
Run daily (e.g. 9 AM) so alerts fire even if the triggering transaction was
|
||||
imported via a provider sync rather than entered manually through the UI.
|
||||
|
||||
python scripts/check_budget_alerts.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from datetime import date
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
from app.models.user import User
|
||||
from app.models.budget import Budget
|
||||
from app.services.alert_service import (
|
||||
_smtp_configured, _send_single_alert_email
|
||||
)
|
||||
from app.services.budget_service import get_month_spending
|
||||
|
||||
user = User.query.first()
|
||||
if not user or not user.budget_alerts_enabled:
|
||||
print("Budget alerts disabled — nothing to do.")
|
||||
sys.exit(0)
|
||||
|
||||
if not _smtp_configured():
|
||||
print("SMTP not configured — nothing to do.")
|
||||
sys.exit(0)
|
||||
|
||||
month_str = date.today().strftime('%Y-%m')
|
||||
symbol = user.currency_symbol or '$'
|
||||
budgets = Budget.query.filter_by(month=month_str).all()
|
||||
sent = 0
|
||||
|
||||
for b in budgets:
|
||||
limit = float(b.limit_amount) + float(b.rollover_amount or 0)
|
||||
if limit <= 0:
|
||||
continue
|
||||
spent = get_month_spending(b.category_id, month_str)
|
||||
pct = spent / limit * 100
|
||||
cat = b.category
|
||||
name = cat.name if cat else 'Unknown'
|
||||
|
||||
if pct >= 100 and not b.alert_sent_100:
|
||||
_send_single_alert_email(name, spent, limit, pct, symbol, threshold=100)
|
||||
b.alert_sent_100 = True
|
||||
sent += 1
|
||||
elif pct >= 80 and not b.alert_sent_80:
|
||||
_send_single_alert_email(name, spent, limit, pct, symbol, threshold=80)
|
||||
b.alert_sent_80 = True
|
||||
sent += 1
|
||||
|
||||
if sent:
|
||||
db.session.commit()
|
||||
|
||||
print(f"Budget alert check complete — {sent} email(s) sent.")
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app
|
||||
from app.services.investment_service import update_prices
|
||||
from app.services.investment_service import update_prices, check_and_save_price_alerts
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -23,3 +23,7 @@ if __name__ == '__main__':
|
||||
print(f'[fetch_prices] Updated {len(updated)} ticker(s).')
|
||||
else:
|
||||
print('[fetch_prices] No tickers updated.')
|
||||
|
||||
print('[fetch_prices] Checking price alerts (threshold=5%)...')
|
||||
n = check_and_save_price_alerts(threshold=5.0)
|
||||
print(f'[fetch_prices] {n} alert(s) saved.')
|
||||
|
||||
Reference in New Issue
Block a user