Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790eb9894e | ||
|
|
5db79313a9 | ||
|
|
231a9d2193 | ||
|
|
e4007348f8 | ||
|
|
025f3f8823 | ||
|
|
458044201e | ||
|
|
9c9aa694c4 | ||
|
|
db44d6057b | ||
|
|
1a4e68b422 | ||
|
|
04acefd9f8 |
@@ -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
|
- **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
|
- 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
|
- Connects 12,000+ US financial institutions via Plaid API
|
||||||
- **Plaid page** (`/plaid/`) — accessible via Settings; connect/disconnect/sync/billing
|
- **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
|
- **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
|
||||||
@@ -277,6 +296,8 @@ 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>`
|
- `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
|
- `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.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
|
||||||
|
|
||||||
### Migration Scripts
|
### Migration Scripts
|
||||||
```
|
```
|
||||||
@@ -326,7 +347,7 @@ pfm/ # /home/pfm/web on server
|
|||||||
│ │
|
│ │
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── auth.py # + TOTP verify/setup/disable routes; rate limits; audit calls
|
│ │ ├── 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
|
│ │ ├── accounts.py # teller_map + schwab_map + plaid_map; skip calc_balance for providers
|
||||||
│ │ ├── categories.py
|
│ │ ├── categories.py
|
||||||
│ │ ├── transactions.py # + set-category AJAX; bulk-action AJAX; quick date filter vars; fixed income-form-submits-as-expense bug
|
│ │ ├── transactions.py # + set-category AJAX; bulk-action AJAX; quick date filter vars; fixed income-form-submits-as-expense bug
|
||||||
@@ -335,7 +356,7 @@ pfm/ # /home/pfm/web on server
|
|||||||
│ │ ├── investments.py # + sync-schwab route; price-history API
|
│ │ ├── investments.py # + sync-schwab route; price-history API
|
||||||
│ │ ├── reports.py
|
│ │ ├── reports.py
|
||||||
│ │ ├── ai.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
|
│ │ ├── teller.py # balance uses ledger/available correctly
|
||||||
│ │ ├── schwab.py # OAuth, mapping, sync, snapshot; audit calls; fallback type 'other'
|
│ │ ├── schwab.py # OAuth, mapping, sync, snapshot; audit calls; fallback type 'other'
|
||||||
│ │ ├── plaid.py # Link flow, exchange, map, sync preview/confirm, balance, liabilities, resync, disconnect
|
│ │ ├── plaid.py # Link flow, exchange, map, sync preview/confirm, balance, liabilities, resync, disconnect
|
||||||
@@ -346,11 +367,13 @@ pfm/ # /home/pfm/web on server
|
|||||||
│ │ ├── account_service.py
|
│ │ ├── account_service.py
|
||||||
│ │ ├── ai_service.py
|
│ │ ├── ai_service.py
|
||||||
│ │ ├── budget_service.py
|
│ │ ├── budget_service.py
|
||||||
│ │ ├── export_service.py
|
│ │ ├── export_service.py # CSV/Excel exports stream via generator + yield_per(500)
|
||||||
│ │ ├── fx_service.py
|
│ │ ├── fx_service.py
|
||||||
│ │ ├── goal_service.py
|
│ │ ├── goal_service.py
|
||||||
│ │ ├── import_service.py
|
│ │ ├── import_service.py
|
||||||
│ │ ├── investment_service.py # get_portfolio_summary returns account_groups
|
│ │ ├── 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
|
│ │ ├── ocr_service.py
|
||||||
│ │ ├── recurring_service.py
|
│ │ ├── recurring_service.py
|
||||||
│ │ ├── report_service.py
|
│ │ ├── report_service.py
|
||||||
@@ -527,6 +550,8 @@ sentry-sdk[flask]==2.7.0
|
|||||||
## 12. UI/UX
|
## 12. UI/UX
|
||||||
|
|
||||||
- **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay; Teller Sync and Schwab Sync **removed** — accessible via Settings only
|
- **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay; Teller Sync and Schwab Sync **removed** — accessible via Settings only
|
||||||
|
- **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)
|
- **Charts**: Chart.js 4.x (CDN)
|
||||||
- **Forms**: WTForms + Bootstrap 5.3
|
- **Forms**: WTForms + Bootstrap 5.3
|
||||||
- **Icons**: Bootstrap Icons 1.11
|
- **Icons**: Bootstrap Icons 1.11
|
||||||
@@ -535,6 +560,7 @@ sentry-sdk[flask]==2.7.0
|
|||||||
- **CSS**: All inline in templates (no build step)
|
- **CSS**: All inline in templates (no build step)
|
||||||
- **CSRF meta tag**: `<meta name="csrf-token">` in `base.html` for JS fetch calls
|
- **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 %}`
|
- **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,6 +629,13 @@ PLAID_ENV=sandbox
|
|||||||
SENTRY_DSN= # leave blank to disable Sentry
|
SENTRY_DSN= # leave blank to disable Sentry
|
||||||
SESSION_IDLE_MINUTES=60 # session idle timeout in minutes
|
SESSION_IDLE_MINUTES=60 # session idle timeout in minutes
|
||||||
RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits across Gunicorn workers
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -613,7 +646,7 @@ RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits a
|
|||||||
|-----------|--------|------------|
|
|-----------|--------|------------|
|
||||||
| health | (none) | /health (public, no auth) |
|
| health | (none) | /health (public, no auth) |
|
||||||
| auth | /auth | login, logout, totp/verify, totp/setup, totp/disable |
|
| 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 |
|
| accounts | /accounts | CRUD, adjust |
|
||||||
| categories | /categories | CRUD |
|
| categories | /categories | CRUD |
|
||||||
| transactions | /transactions | index, new, edit, delete, transfer, ocr, ocr-file, `<id>/set-category`, bulk-action |
|
| transactions | /transactions | index, new, edit, delete, transfer, ocr, ocr-file, `<id>/set-category`, bulk-action |
|
||||||
@@ -622,7 +655,7 @@ 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 |
|
| 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 |
|
| reports | /reports | monthly, quarterly, yearly, tax, export/csv\|excel\|pdf, snapshot |
|
||||||
| ai | /ai | index, stream (SSE), history, generate-insight |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -683,22 +716,26 @@ RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits a
|
|||||||
## 19. To-Do / Roadmap
|
## 19. To-Do / Roadmap
|
||||||
|
|
||||||
### High priority
|
### High priority
|
||||||
- [ ] **Mobile responsiveness pass** — sidebar auto-collapses on mobile; tables scroll horizontally *(base.html mobile CSS improved; further work needed)*
|
*(none currently open)*
|
||||||
- [ ] **Budget alerts** — email/Twilio SMS when category spending hits 80% / 100%
|
|
||||||
|
|
||||||
### Medium priority
|
### Medium priority
|
||||||
- [ ] **PDF export memory** — stream CSV/Excel exports for users with large transaction history
|
*(none currently open)*
|
||||||
- [ ] **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
|
|
||||||
|
|
||||||
### Low priority / future
|
### Low priority / future
|
||||||
- [ ] iOS companion app
|
- [ ] iOS companion app
|
||||||
- [ ] Shared household mode (2 users, row-level isolation)
|
- [ ] Shared household mode (2 users, row-level isolation)
|
||||||
- [ ] Bank statement PDF: table-extraction fallback (pdfplumber tables API) before Groq call
|
- [ ] 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)
|
### 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] **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] **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
|
- [x] **Investment price history chart** — 1W/1M/3M/6M/1Y chart on investment detail page
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PFM — Personal Finance Manager
|
# 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
|
**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)
|
10. [AI Assistant](#ai-assistant)
|
||||||
11. [Reports & Export](#reports--export)
|
11. [Reports & Export](#reports--export)
|
||||||
12. [Settings](#settings)
|
12. [Settings](#settings)
|
||||||
13. [Recurring Transactions](#recurring-transactions)
|
13. [Security & 2FA](#security--2fa)
|
||||||
14. [CSV Import](#csv-import)
|
14. [Recurring Transactions](#recurring-transactions)
|
||||||
15. [Receipt OCR](#receipt-ocr)
|
15. [Bank Statement Import](#bank-statement-import)
|
||||||
16. [Teller Bank Sync](#teller-bank-sync)
|
16. [Receipt OCR](#receipt-ocr)
|
||||||
17. [Schwab Bank Sync](#schwab-bank-sync)
|
17. [Teller Bank Sync](#teller-bank-sync)
|
||||||
18. [USD → VND Rate Widget](#usd--vnd-rate-widget)
|
18. [Schwab Bank Sync](#schwab-bank-sync)
|
||||||
19. [Keyboard Shortcuts & Tips](#keyboard-shortcuts--tips)
|
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 |
|
| Module | What it does |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **Dashboard** | Net worth, cash flow chart, budget gauges, AI insight, USD/VND rate |
|
| **Dashboard** | Net worth, savings rate, cash flow chart, budget gauges, upcoming bills, reconcile, AI insight, USD/VND rate |
|
||||||
| **Transactions** | Income + expense entry, transfer, filter, search, inline category change, receipt upload |
|
| **Transactions** | Income/expense entry, transfer, inline category, bulk actions, quick date filters, duplicate detection, split transaction, filtered export |
|
||||||
| **Accounts** | Multiple bank/cash/credit accounts, auto-calculated or provider-synced balances |
|
| **Accounts** | Multiple account types, auto-calculated or provider-synced balances (Teller / Plaid / Schwab) |
|
||||||
| **Categories** | Custom expense + income categories with color and icon |
|
| **Categories** | Custom expense + income categories with color and icon |
|
||||||
| **Budgets** | Monthly spending limits per category with progress tracking |
|
| **Budgets** | Monthly spending limits with progress bars, budget vs actual chart, rollover support |
|
||||||
| **Goals** | Savings goals with contribution tracking and projected completion |
|
| **Goals** | Savings goals with contribution tracking, projected completion, emergency fund tracker |
|
||||||
| **Investments** | Stock/ETF/crypto/real estate portfolio, per-account view (Individual vs Roth IRA), live price fetch |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Reports** | Monthly/quarterly/yearly summaries, net worth history, category spending trends, PDF/CSV/Excel export |
|
||||||
| **Teller** | Live US bank account sync (mTLS), balance refresh, transaction import |
|
| **Teller** | Live US bank sync (mTLS), balance refresh, transaction import, webhook |
|
||||||
| **Schwab** | Schwab brokerage sync — balance, stock/ETF positions, transaction import |
|
| **Schwab** | Schwab brokerage sync — balance, stock/ETF positions, transaction import, daily auto-sync |
|
||||||
| **Settings** | Profile, currency, recurring rules, CSV import, password |
|
| **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
|
### Recommended Setup Order
|
||||||
|
|
||||||
1. **Add accounts** — add your bank accounts, cash wallet, and credit cards first
|
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
|
2. **Review categories** — 21 default categories are seeded; add custom ones if needed
|
||||||
3. **Set your currency** — go to Settings → Profile and set your app currency
|
3. **Set your currency** — go to Settings → Profile and pick from 8 currency options
|
||||||
4. **Connect bank sync** — connect Teller (US banks) or Schwab for automatic data
|
4. **Connect bank sync** — connect Teller (US banks), Plaid (12,000+ institutions), or Schwab for automatic data
|
||||||
5. **Add transactions** — start entering income and expenses (or import via bank sync)
|
5. **Add transactions** — enter income/expenses manually, import a bank statement, or sync from a provider
|
||||||
6. **Set budgets** — once you have categories, set monthly limits
|
6. **Set budgets** — once you have spending data, set monthly limits per category
|
||||||
7. **Create goals** — add savings goals and start contributing
|
7. **Create goals** — add savings goals and start contributing
|
||||||
8. **Add investments** — manually or via Schwab sync
|
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.
|
The dashboard is the home screen — accessible from the sidebar or by clicking the PFM logo.
|
||||||
|
|
||||||
### Period Selector
|
### 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
|
### Summary Cards
|
||||||
- **Income** — total income for the selected period
|
- **Income** — total income for the selected period
|
||||||
- **Expenses** — total expenses for the selected period
|
- **Expenses** — total expenses for the selected period
|
||||||
- **Net Cash Flow** — income minus expenses
|
- **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
|
### Cash Flow Chart
|
||||||
Bar chart showing the last 6 months of income (green) vs expenses (red).
|
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
|
### 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
|
### 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
|
### 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
|
## Transactions
|
||||||
|
|
||||||
### Viewing 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
|
### 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
|
### 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
|
### 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
|
### 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
|
### 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
|
## Accounts
|
||||||
|
|
||||||
### Account Types
|
### Account Types
|
||||||
checking / savings / cash / credit_card / crypto / investment / other
|
`checking` / `savings` / `cash` / `credit_card` / `crypto` / `investment` / `other`
|
||||||
|
|
||||||
### Balance Sources
|
### Balance Sources
|
||||||
- **Manually managed accounts** — balance is computed from all transactions (income − expenses − transfers out + transfers in). Updates automatically after every transaction.
|
- **Manually managed** — balance is computed from transactions (income − expenses ± transfers). Updates 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.
|
- **Teller-linked** — live balance from Teller API. Blue **Teller** badge. Updated after sync or Refresh click.
|
||||||
- **Schwab-linked accounts** — balance comes from Schwab's snapshot sync. Shown with a green **Schwab** badge. Use the action buttons on the account card.
|
- **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 |
|
| Button | What it does |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **Refresh** | Pulls the live balance from Teller API (AJAX, no page reload) |
|
| **Refresh** | Pulls live balance from Teller API (AJAX) |
|
||||||
| **Sync** | Opens transaction preview — import new transactions from this account |
|
| **Sync** | Opens transaction preview — import new transactions |
|
||||||
| **Reset** | Clears the sync cursor — next Sync will re-fetch the full 90-day history |
|
| **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 |
|
| Button | What it does |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **Balance & Positions** | Fetches live balance + investment holdings from Schwab, updates immediately |
|
| **Balance & Positions** | Fetches live balance + investment holdings from Schwab |
|
||||||
| **Transactions** | Opens transaction import preview for this account |
|
| **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
|
||||||
Credit cards show two values:
|
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
|
- **This Month** — expenses charged to the card this calendar month
|
||||||
|
|
||||||
### Opening Balance
|
### 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
|
**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.
|
Navigate via **Budgets** in the sidebar. Use ◀ ▶ to navigate months.
|
||||||
|
|
||||||
- Set monthly limits per expense category
|
### Setting Budgets
|
||||||
- Progress bars: green (< 80%) → amber (80–99%) → red (100%+)
|
Click **Add Budget** in the topbar to set a monthly limit for a category. Click **Edit** on an existing row to adjust.
|
||||||
- Rollover toggle: unspent budget carries forward to next month
|
|
||||||
- **Copy from previous month** — duplicate last month's budgets in one click
|
### 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.
|
Navigate via **Goals** in the sidebar.
|
||||||
|
|
||||||
- Create goals with target amount, target date, color, and icon
|
- Create goals with target amount, target date, color, and icon
|
||||||
- Add contributions with dates and notes
|
- Add contributions with dates and notes; view contribution history
|
||||||
- Auto-completes when target is reached
|
- Progress bar + projected completion date (based on average monthly contribution)
|
||||||
- **Emergency Fund Tracker** — shows how many months your liquid assets cover at current spending
|
- 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.
|
Navigate via **Investments** in the sidebar.
|
||||||
|
|
||||||
### Portfolio Overview
|
### 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
|
### 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
|
### 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
|
### Adding a Holding Manually
|
||||||
Click **Add Holding** in the topbar. After saving, go to the detail page to record buy transactions.
|
Click **Add Holding** in the topbar. After saving, go to the detail page to record buy transactions.
|
||||||
|
|
||||||
### Ticker Format
|
### Ticker Format
|
||||||
- US Stocks: `AAPL`, `MSFT`
|
- US Stocks/ETFs: `AAPL`, `VOO`, `QQQ`
|
||||||
- ETFs: `VOO`, `QQQ`
|
|
||||||
- Crypto: `BTC-USD`, `ETH-USD`
|
- Crypto: `BTC-USD`, `ETH-USD`
|
||||||
- Other markets: Yahoo Finance suffix (e.g. `VIC.VN`)
|
- International: Yahoo Finance suffix (e.g. `VIC.VN`)
|
||||||
|
|
||||||
### Refreshing Prices
|
### 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.
|
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
|
- Type a message and press **Enter** to send; **Shift+Enter** for a new line
|
||||||
- Eight quick-question suggestion buttons on the right
|
- Responses stream word-by-word (SSE)
|
||||||
- **Daily Insight** — auto-generated summary at midnight; click **Generate Now** to create on demand
|
- Eight quick-question suggestion buttons
|
||||||
- **Chat History** button — all past responses with timestamps
|
- **Daily Insight** — auto-generated at midnight; click **Generate Now** to create on demand
|
||||||
|
- **Chat History** — all past responses with timestamps
|
||||||
|
|
||||||
### AI Model
|
### 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**.
|
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 |
|
| Section | What you can change |
|
||||||
|---------|-------------------|
|
|---------|-------------------|
|
||||||
| Profile | Display name, email, timezone, currency, AI model |
|
| **Profile** | Display name, email, timezone, currency (8 options), AI model |
|
||||||
| Password | Current password + new password (min 6 chars) |
|
| **Password** | Change password (requires current password) |
|
||||||
| Recurring | Create/edit/pause recurring transaction rules |
|
| **Security** | Enable/disable TOTP 2FA; view Audit Log |
|
||||||
| Import | Upload CSV to bulk-import transactions |
|
| **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**.
|
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
|
## 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 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
|
### 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
|
2. Click **Connect a Bank** — the Teller Connect modal opens
|
||||||
3. Select your bank and log in
|
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
|
### 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
|
### Webhook
|
||||||
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.
|
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
|
### 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 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
|
### Connecting
|
||||||
1. Set `SCHWAB_CLIENT_ID`, `SCHWAB_CLIENT_SECRET`, `SCHWAB_REDIRECT_URI` in `.env`
|
1. Set `SCHWAB_CLIENT_ID`, `SCHWAB_CLIENT_SECRET`, `SCHWAB_REDIRECT_URI` in `.env`
|
||||||
2. Register the redirect URI in the Schwab developer portal
|
2. Register the redirect URI in the Schwab developer portal (exact match required)
|
||||||
3. Go to **Schwab** in the sidebar → **Connect Schwab Account**
|
3. Settings → **Schwab** → **Connect Schwab Account**
|
||||||
4. Authorize on Schwab's login page
|
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)
|
5. On the **Map Accounts** page, link each Schwab account to a PFM account (or create new ones)
|
||||||
|
|
||||||
### Syncing Balance & Investment Positions
|
### Syncing Balance & Investment Positions
|
||||||
On the **Accounts** page, find your Schwab-linked account (green **Schwab** badge) and click **Balance & Positions**. This:
|
On the **Accounts** page, click **Balance & Positions** on a Schwab account (green badge). This:
|
||||||
- Updates the account's balance to Schwab's total portfolio value
|
- Updates the account balance to Schwab's total portfolio liquidation value
|
||||||
- Imports all stock/ETF/bond/fund holdings into the Investments section
|
- Upserts stock/ETF/bond/fund holdings into the Investments section
|
||||||
- Associates each holding with the specific account (Individual vs Roth IRA stay separate)
|
- 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
|
### Syncing Transactions
|
||||||
Click **Transactions** on a Schwab account card. Review the preview and confirm to import.
|
Click **Transactions** on a Schwab account card. Review the preview and confirm to import.
|
||||||
|
|
||||||
### Per-Account Investment View
|
### Token Expiry
|
||||||
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).
|
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
|
### 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.
|
Reference-only exchange rate on the dashboard. Does not affect any app calculations.
|
||||||
|
|
||||||
- Click ↻ to force-refresh without reloading the page
|
- Click ↻ to force-refresh without reloading the page
|
||||||
- Click the widget to show a 30-day trend chart
|
- Click the widget to expand a 30-day trend chart
|
||||||
- ⚠ indicator appears if the cached rate is more than a day old
|
- 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
|
## Keyboard Shortcuts & Tips
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
- Sidebar collapses on desktop — click ☰ to toggle (state remembered)
|
- Sidebar collapses on desktop — click ☰ to toggle (state saved in localStorage)
|
||||||
- On mobile, tap outside the sidebar to close it
|
- On mobile, tap outside the sidebar to close it; tables scroll horizontally
|
||||||
|
|
||||||
### Transactions
|
### Transactions
|
||||||
- **Enter** sends in AI chat; **Shift+Enter** adds a new line
|
- **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
|
- Change category directly in the transactions table — no form needed
|
||||||
- Date defaults to today; change it for past transactions
|
- Date defaults to today — change it for past transactions
|
||||||
|
- Use the **✂ Split** button on a row to divide a transaction across multiple categories
|
||||||
### Investments
|
- Export the current filtered view with the **CSV** / **Excel** buttons in the filter bar
|
||||||
- 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
|
|
||||||
|
|
||||||
### Budgets
|
### Budgets
|
||||||
- **Copy from previous month** button at the start of each month saves re-entering all budgets
|
- **Copy from previous month** at the start of each month saves re-entering all budgets
|
||||||
- Rollover amounts appear as a blue "+rollover" badge
|
- 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
|
### Reports
|
||||||
- **Snapshot Now** button saves today's net worth to the history chart
|
- **Snapshot Now** saves today's net worth to the history chart immediately
|
||||||
- CSV/Excel exports use the period currently selected in the report view
|
- 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
|
||||||
|
|||||||
@@ -148,6 +148,22 @@ def create_app(config_name=None):
|
|||||||
from app.models.app_log import AppLog
|
from app.models.app_log import AppLog
|
||||||
from app.models.audit_log import AuditLog
|
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}
|
||||||
|
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
|
||||||
|
return ctx
|
||||||
|
|
||||||
# ── Session idle timeout ──────────────────────────────────────────────────
|
# ── Session idle timeout ──────────────────────────────────────────────────
|
||||||
from flask import session as _session, request as _request
|
from flask import session as _session, request as _request
|
||||||
from flask_login import current_user as _cu
|
from flask_login import current_user as _cu
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class Budget(db.Model):
|
|||||||
limit_amount = db.Column(db.Numeric(15, 2), nullable=False)
|
limit_amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||||
rollover_enabled = db.Column(db.Boolean, default=False)
|
rollover_enabled = db.Column(db.Boolean, default=False)
|
||||||
rollover_amount = db.Column(db.Numeric(15, 2), default=0.00)
|
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)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
category = db.relationship('Category')
|
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')
|
groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile')
|
||||||
totp_secret = db.Column(db.String(64), nullable=True)
|
totp_secret = db.Column(db.String(64), nullable=True)
|
||||||
totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
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)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
last_login = db.Column(db.DateTime, nullable=True)
|
last_login = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
|||||||
+39
-1
@@ -5,11 +5,15 @@ from app.extensions import db
|
|||||||
from app.models.account import Account
|
from app.models.account import Account
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.models.category import Category
|
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.fx_service import get_today_rate, get_rate_history, force_refresh
|
||||||
from app.services.ai_service import get_latest_daily_insight
|
from app.services.ai_service import get_latest_daily_insight
|
||||||
from app.services.account_service import get_total_assets, get_total_liabilities
|
from app.services.account_service import get_total_assets, get_total_liabilities
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
import calendar
|
import calendar
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger('app.dashboard')
|
||||||
|
|
||||||
dashboard_bp = Blueprint('dashboard', __name__)
|
dashboard_bp = Blueprint('dashboard', __name__)
|
||||||
|
|
||||||
@@ -142,6 +146,14 @@ def index():
|
|||||||
chart_income.append(float(inc))
|
chart_income.append(float(inc))
|
||||||
chart_expense.append(float(exp))
|
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 transactions ───────────────────────────
|
||||||
recent_txns = Transaction.query\
|
recent_txns = Transaction.query\
|
||||||
.filter(Transaction.transaction_type.in_(['income', 'expense']))\
|
.filter(Transaction.transaction_type.in_(['income', 'expense']))\
|
||||||
@@ -182,7 +194,9 @@ def index():
|
|||||||
fx=fx,
|
fx=fx,
|
||||||
fx_history_data=fx_history_data,
|
fx_history_data=fx_history_data,
|
||||||
ai_insight=ai_insight,
|
ai_insight=ai_insight,
|
||||||
schwab_warning=schwab_warning)
|
schwab_warning=schwab_warning,
|
||||||
|
upcoming_bills=upcoming_bills,
|
||||||
|
today=today)
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route('/api/reconcile')
|
@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')
|
@dashboard_bp.route('/api/fx-history')
|
||||||
@login_required
|
@login_required
|
||||||
def fx_history_api():
|
def fx_history_api():
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.models.investment import Investment, InvestmentTransaction
|
|||||||
from app.services.investment_service import (
|
from app.services.investment_service import (
|
||||||
get_portfolio_summary, update_prices, fetch_price,
|
get_portfolio_summary, update_prices, fetch_price,
|
||||||
fetch_price_history, fetch_day_change,
|
fetch_price_history, fetch_day_change,
|
||||||
|
get_price_alerts,
|
||||||
ASSET_COLORS, ASSET_TYPE_LABELS
|
ASSET_COLORS, ASSET_TYPE_LABELS
|
||||||
)
|
)
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -341,6 +342,13 @@ def api_day_change(ticker):
|
|||||||
return jsonify(data)
|
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>')
|
@investments_bp.route('/api/history/<ticker>')
|
||||||
@login_required
|
@login_required
|
||||||
def api_price_history(ticker):
|
def api_price_history(ticker):
|
||||||
|
|||||||
+13
-10
@@ -1,14 +1,14 @@
|
|||||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
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 flask_login import login_required, current_user
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.services.report_service import (
|
from app.services.report_service import (
|
||||||
monthly_report, quarterly_report, yearly_report,
|
monthly_report, quarterly_report, yearly_report,
|
||||||
net_worth_history, category_trends, tax_year_summary,
|
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 (
|
from app.services.export_service import (
|
||||||
transactions_to_csv, transactions_to_excel,
|
transactions_csv_stream, transactions_to_excel,
|
||||||
report_to_pdf, build_report_html,
|
report_to_pdf, build_report_html,
|
||||||
)
|
)
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -24,16 +24,16 @@ _CUR_MONTH = date.today().month
|
|||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
today = date.today()
|
today = date.today()
|
||||||
# Default: current month summary
|
|
||||||
report = monthly_report(today.year, today.month)
|
report = monthly_report(today.year, today.month)
|
||||||
nw_history = net_worth_history()
|
nw_history = net_worth_history()
|
||||||
cat_trend = category_trends(6)
|
cat_trend = category_trends(6)
|
||||||
|
mom_data = category_mom_comparison()
|
||||||
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
||||||
return render_template('reports/index.html',
|
return render_template('reports/index.html',
|
||||||
report=report,
|
report=report,
|
||||||
nw_history=nw_history,
|
nw_history=nw_history,
|
||||||
cat_trend=cat_trend,
|
cat_trend=cat_trend,
|
||||||
|
mom_data=mom_data,
|
||||||
years=years,
|
years=years,
|
||||||
current_year=_CUR_YEAR,
|
current_year=_CUR_YEAR,
|
||||||
current_month=_CUR_MONTH,
|
current_month=_CUR_MONTH,
|
||||||
@@ -51,11 +51,13 @@ def monthly():
|
|||||||
report = monthly_report(year, month)
|
report = monthly_report(year, month)
|
||||||
nw_history = net_worth_history()
|
nw_history = net_worth_history()
|
||||||
cat_trend = category_trends(6)
|
cat_trend = category_trends(6)
|
||||||
|
mom_data = category_mom_comparison()
|
||||||
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
|
||||||
return render_template('reports/index.html',
|
return render_template('reports/index.html',
|
||||||
report=report,
|
report=report,
|
||||||
nw_history=nw_history,
|
nw_history=nw_history,
|
||||||
cat_trend=cat_trend,
|
cat_trend=cat_trend,
|
||||||
|
mom_data=mom_data,
|
||||||
years=years,
|
years=years,
|
||||||
current_year=_CUR_YEAR,
|
current_year=_CUR_YEAR,
|
||||||
current_month=_CUR_MONTH,
|
current_month=_CUR_MONTH,
|
||||||
@@ -78,6 +80,7 @@ def quarterly():
|
|||||||
report=report,
|
report=report,
|
||||||
nw_history=nw_history,
|
nw_history=nw_history,
|
||||||
cat_trend=cat_trend,
|
cat_trend=cat_trend,
|
||||||
|
mom_data=[],
|
||||||
years=years,
|
years=years,
|
||||||
current_year=_CUR_YEAR,
|
current_year=_CUR_YEAR,
|
||||||
current_month=_CUR_MONTH,
|
current_month=_CUR_MONTH,
|
||||||
@@ -99,6 +102,7 @@ def yearly():
|
|||||||
report=report,
|
report=report,
|
||||||
nw_history=nw_history,
|
nw_history=nw_history,
|
||||||
cat_trend=cat_trend,
|
cat_trend=cat_trend,
|
||||||
|
mom_data=[],
|
||||||
years=years,
|
years=years,
|
||||||
current_year=_CUR_YEAR,
|
current_year=_CUR_YEAR,
|
||||||
current_month=_CUR_MONTH,
|
current_month=_CUR_MONTH,
|
||||||
@@ -137,14 +141,13 @@ def export_csv():
|
|||||||
)
|
)
|
||||||
if txn_type != 'all':
|
if txn_type != 'all':
|
||||||
query = query.filter(Transaction.transaction_type == txn_type)
|
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)
|
label = f'{year}-{month:02d}' if month else str(year)
|
||||||
filename = f'pfm_transactions_{label}.csv'
|
filename = f'pfm_transactions_{label}.csv'
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
csv_data,
|
stream_with_context(transactions_csv_stream(query)),
|
||||||
mimetype='text/csv',
|
mimetype='text/csv',
|
||||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
||||||
)
|
)
|
||||||
@@ -163,11 +166,11 @@ def export_excel():
|
|||||||
__import__('calendar').monthrange(year, month)[1]),
|
__import__('calendar').monthrange(year, month)[1]),
|
||||||
Transaction.transaction_type.in_(['income', 'expense']),
|
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)
|
label = f'{year}-{month:02d}' if month else str(year)
|
||||||
period_label = f'{year} Month {month}' 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'
|
filename = f'pfm_transactions_{label}.xlsx'
|
||||||
|
|
||||||
return send_file(
|
return send_file(
|
||||||
|
|||||||
+36
-2
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
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 flask_login import login_required, current_user
|
||||||
from app.utils.audit import audit
|
from app.utils.audit import audit
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
@@ -68,6 +68,7 @@ class ProfileForm(FlaskForm):
|
|||||||
])
|
])
|
||||||
currency = SelectField('Currency', choices=CURRENCIES)
|
currency = SelectField('Currency', choices=CURRENCIES)
|
||||||
groq_model = SelectField('AI Model', choices=GROQ_MODELS)
|
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')
|
submit = SubmitField('Save Profile')
|
||||||
|
|
||||||
|
|
||||||
@@ -156,10 +157,28 @@ def profile():
|
|||||||
current_user.currency = form.currency.data
|
current_user.currency = form.currency.data
|
||||||
current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$')
|
current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$')
|
||||||
current_user.groq_model = form.groq_model.data
|
current_user.groq_model = form.groq_model.data
|
||||||
|
current_user.budget_alerts_enabled = form.budget_alerts_enabled.data
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Profile updated.', 'success')
|
flash('Profile updated.', 'success')
|
||||||
return redirect(url_for('settings.profile'))
|
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'])
|
@settings_bp.route('/password', methods=['GET', 'POST'])
|
||||||
@@ -189,6 +208,21 @@ def recurring():
|
|||||||
return render_template('settings/recurring.html', rules=rules, upcoming=upcoming)
|
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'])
|
@settings_bp.route('/recurring/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def recurring_new():
|
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_login import login_required
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
|
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
|
||||||
@@ -61,39 +61,17 @@ class TransferForm(FlaskForm):
|
|||||||
submit = SubmitField('Transfer')
|
submit = SubmitField('Transfer')
|
||||||
|
|
||||||
|
|
||||||
@transactions_bp.route('/')
|
def _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min='', amount_max=''):
|
||||||
@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 = ''
|
|
||||||
|
|
||||||
query = Transaction.query.filter(
|
query = Transaction.query.filter(
|
||||||
Transaction.transaction_type == tab
|
Transaction.transaction_type == tab
|
||||||
).order_by(Transaction.date.desc(), Transaction.id.desc())
|
).order_by(Transaction.date.desc(), Transaction.id.desc())
|
||||||
|
|
||||||
if search:
|
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:
|
try:
|
||||||
if category_id:
|
if category_id:
|
||||||
query = query.filter(Transaction.category_id == int(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())
|
query = query.filter(Transaction.date <= datetime.strptime(date_to, '%Y-%m-%d').date())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
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)
|
pagination = query.paginate(page=page, per_page=30, error_out=False)
|
||||||
|
|
||||||
@@ -137,6 +155,8 @@ def index():
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
|
amount_min=amount_min,
|
||||||
|
amount_max=amount_max,
|
||||||
active_quick=active_quick,
|
active_quick=active_quick,
|
||||||
this_month_from=this_month_from,
|
this_month_from=this_month_from,
|
||||||
this_month_to=this_month_to,
|
this_month_to=this_month_to,
|
||||||
@@ -427,12 +447,21 @@ def ocr_receipt_file():
|
|||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
from app.models.receipt import Receipt
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or not data.get('filename'):
|
if not data or not data.get('filename'):
|
||||||
return jsonify({'error': 'No filename provided'}), 400
|
return jsonify({'error': 'No filename provided'}), 400
|
||||||
|
|
||||||
# Security: only allow basenames, no path traversal
|
# Security: only allow basenames, no path traversal
|
||||||
filename = os.path.basename(data['filename'])
|
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')
|
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
|
||||||
file_path = os.path.join(upload_dir, filename)
|
file_path = os.path.join(upload_dir, filename)
|
||||||
|
|
||||||
@@ -458,3 +487,159 @@ def ocr_receipt_file():
|
|||||||
'category_suggestion': result['category_suggestion'],
|
'category_suggestion': result['category_suggestion'],
|
||||||
'category_id': category_id,
|
'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)
|
||||||
|
|||||||
@@ -5,48 +5,45 @@ plus any incoming transfers minus outgoing transfers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, case, or_, and_
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.account import Account
|
from app.models.account import Account
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
|
|
||||||
|
|
||||||
def calc_balance(account_id):
|
def calc_balance(account_id):
|
||||||
"""Recalculate and persist the balance for a given account."""
|
"""
|
||||||
# Income credited to this account
|
Recalculate and persist the balance for a given account.
|
||||||
income = db.session.query(
|
Uses a single aggregation query with CASE expressions instead of 4 queries.
|
||||||
func.coalesce(func.sum(Transaction.amount), 0)
|
"""
|
||||||
|
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(
|
).filter(
|
||||||
Transaction.account_id == account_id,
|
or_(Transaction.account_id == account_id,
|
||||||
Transaction.transaction_type == 'income'
|
Transaction.to_account_id == account_id)
|
||||||
).scalar()
|
).one()
|
||||||
|
|
||||||
# Expenses debited from this account
|
income, expense, transfer_out, transfer_in = (Decimal(str(v)) for v in row)
|
||||||
expense = db.session.query(
|
balance = income - expense - transfer_out + transfer_in
|
||||||
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))
|
|
||||||
|
|
||||||
account = db.session.get(Account, account_id)
|
account = db.session.get(Account, account_id)
|
||||||
if account:
|
if account:
|
||||||
|
|||||||
+123
-43
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Alert Service — budget threshold alerts triggered after expense transactions.
|
Alert Service — budget threshold alerts triggered after expense transactions.
|
||||||
|
|
||||||
Checks all budgeted categories for the current month and returns alert dicts
|
Checks budgeted categories and flashes/emails when spending crosses 80% or 100%.
|
||||||
for any that just crossed 80% or 100%. Also sends an email if SMTP is configured.
|
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()):
|
Usage (in routes after db.session.commit()):
|
||||||
from app.services.alert_service import check_and_flash_budget_alerts
|
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 email.mime.multipart import MIMEMultipart
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
from app.extensions import db
|
||||||
|
|
||||||
|
log = logging.getLogger('app.alert_service')
|
||||||
|
|
||||||
|
|
||||||
def _smtp_configured():
|
def _smtp_configured():
|
||||||
@@ -35,7 +38,7 @@ def _send_email(subject, body_html):
|
|||||||
host = cfg['SMTP_HOST']
|
host = cfg['SMTP_HOST']
|
||||||
port = int(cfg.get('SMTP_PORT', 587))
|
port = int(cfg.get('SMTP_PORT', 587))
|
||||||
user = cfg['SMTP_USER']
|
user = cfg['SMTP_USER']
|
||||||
password = cfg['SMTP_PASSWORD']
|
pw = cfg['SMTP_PASSWORD']
|
||||||
to_addr = cfg['ALERT_EMAIL']
|
to_addr = cfg['ALERT_EMAIL']
|
||||||
|
|
||||||
msg = MIMEMultipart('alternative')
|
msg = MIMEMultipart('alternative')
|
||||||
@@ -45,31 +48,47 @@ def _send_email(subject, body_html):
|
|||||||
msg.attach(MIMEText(body_html, 'html'))
|
msg.attach(MIMEText(body_html, 'html'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
with smtplib.SMTP(host, port) as srv:
|
with smtplib.SMTP(host, port) as srv:
|
||||||
srv.ehlo()
|
srv.ehlo()
|
||||||
srv.starttls(context=context)
|
srv.starttls(context=ctx)
|
||||||
srv.login(user, password)
|
srv.login(user, pw)
|
||||||
srv.sendmail(user, to_addr, msg.as_string())
|
srv.sendmail(user, to_addr, msg.as_string())
|
||||||
log.info('[alert] email sent: %s → %s', subject, to_addr)
|
log.info('[alert] email sent: %s → %s', subject, to_addr)
|
||||||
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error('[alert] email failed: %s', exc, exc_info=True)
|
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):
|
def get_budget_alerts(month_str=None):
|
||||||
"""
|
"""
|
||||||
Return list of alert dicts for budget categories at or over threshold.
|
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}
|
Each dict: {category, spent, limit, pct, level}
|
||||||
level: 'over' (≥100%) | 'warning' (≥80%)
|
level: 'over' (≥100%) | 'warning' (≥80%)
|
||||||
|
|
||||||
Only returns categories that have a budget set.
|
|
||||||
"""
|
"""
|
||||||
from app.services.budget_service import get_budget_summary
|
from app.services.budget_service import get_budget_summary
|
||||||
|
|
||||||
if month_str is None:
|
if month_str is None:
|
||||||
today = date.today()
|
month_str = date.today().strftime('%Y-%m')
|
||||||
month_str = today.strftime('%Y-%m')
|
|
||||||
|
|
||||||
summary = get_budget_summary(month_str)
|
summary = get_budget_summary(month_str)
|
||||||
alerts = []
|
alerts = []
|
||||||
@@ -81,56 +100,117 @@ def get_budget_alerts(month_str=None):
|
|||||||
alerts.append({**item, 'level': 'over'})
|
alerts.append({**item, 'level': 'over'})
|
||||||
elif pct >= 80:
|
elif pct >= 80:
|
||||||
alerts.append({**item, 'level': 'warning'})
|
alerts.append({**item, 'level': 'warning'})
|
||||||
|
|
||||||
return alerts
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
def check_and_flash_budget_alerts(flash_fn):
|
def check_and_flash_budget_alerts(flash_fn):
|
||||||
"""
|
"""
|
||||||
Check current month's budgets and flash any threshold alerts.
|
Check current month's budgets. For each category that newly crossed 80% or
|
||||||
Optionally sends an email summary if SMTP is configured.
|
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.
|
Call this after committing an expense transaction.
|
||||||
|
flash_fn: Flask's flash() (passed in to avoid circular imports at module level)
|
||||||
flash_fn: Flask's flash() function (passed in to avoid circular imports)
|
|
||||||
"""
|
"""
|
||||||
|
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:
|
try:
|
||||||
alerts = get_budget_alerts()
|
budgets = Budget.query.filter_by(month=month_str).all()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error('[alert] check failed: %s', exc, exc_info=True)
|
log.error('[alert] failed to query budgets: %s', exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not alerts:
|
dirty_budgets = []
|
||||||
return
|
|
||||||
|
|
||||||
email_lines = []
|
for b in budgets:
|
||||||
for a in alerts:
|
try:
|
||||||
cat_name = a['category'].name if a['category'] else 'Unknown'
|
limit = float(b.limit_amount) + float(b.rollover_amount or 0)
|
||||||
pct = a['pct']
|
if limit <= 0:
|
||||||
spent = a['spent']
|
continue
|
||||||
limit = a['limit']
|
spent = get_month_spending(b.category_id, month_str)
|
||||||
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
|
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> — '
|
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')
|
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> — '
|
msg = (f'Budget warning: <strong>{cat_name}</strong> — '
|
||||||
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
|
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
|
||||||
flash_fn(msg, 'warning')
|
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(
|
except Exception as exc:
|
||||||
f'<li><b>{cat_name}</b>: {symbol}{spent:,.2f} / {symbol}{limit:,.2f} '
|
log.error('[alert] error checking budget %s: %s', b.id, exc)
|
||||||
f'({pct:.0f}%) — <b>{"EXCEEDED" if a["level"]=="over" else "Warning"}</b></li>'
|
|
||||||
)
|
|
||||||
|
|
||||||
if _smtp_configured() and email_lines:
|
if dirty_budgets:
|
||||||
today = date.today().strftime('%B %Y')
|
try:
|
||||||
body = (
|
db.session.commit()
|
||||||
f'<h3>PFM Budget Alert — {today}</h3>'
|
except Exception as exc:
|
||||||
f'<p>The following budget categories need your attention:</p>'
|
log.error('[alert] failed to save alert flags: %s', exc)
|
||||||
f'<ul>{"".join(email_lines)}</ul>'
|
db.session.rollback()
|
||||||
f'<p><a href="{current_app.config.get("APP_URL","")}/budgets">View Budgets →</a></p>'
|
|
||||||
)
|
|
||||||
_send_email(f'PFM Budget Alert — {today}', body)
|
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()}
|
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 ────────────────────────────────────────────────────────────────
|
# ── Enrichment ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _enrich(raw_rows):
|
def _enrich(raw_rows):
|
||||||
@@ -572,9 +577,115 @@ def _groq_parse_statement(text):
|
|||||||
return raw_rows
|
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):
|
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).
|
Returns (raw_rows, warnings_list).
|
||||||
Raises RuntimeError for unrecoverable problems (scanned PDF, bad file, etc.).
|
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:
|
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
||||||
num_pages = len(pdf.pages)
|
num_pages = len(pdf.pages)
|
||||||
log.info('[bank_import] PDF has %d page(s)', num_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:
|
for page in pdf.pages:
|
||||||
text = page.extract_text(x_tolerance=2, y_tolerance=2)
|
text = page.extract_text(x_tolerance=2, y_tolerance=2)
|
||||||
if text:
|
if text:
|
||||||
text_parts.append(text)
|
text_parts.append(text)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error('[bank_import] pdfplumber failed: %s', exc, exc_info=True)
|
log.error('[bank_import] pdfplumber failed: %s', exc, exc_info=True)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Export Service — CSV, Excel, and PDF generation for transactions and reports.
|
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
|
import io
|
||||||
@@ -11,13 +16,20 @@ from app.models.transaction import Transaction
|
|||||||
|
|
||||||
# ── CSV export ────────────────────────────────────────────────────────────────
|
# ── CSV export ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def transactions_to_csv(transactions):
|
def transactions_csv_stream(query):
|
||||||
"""Return a CSV string of transactions."""
|
"""
|
||||||
output = io.StringIO()
|
Generator that yields CSV text one row at a time.
|
||||||
writer = csv.writer(output)
|
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'])
|
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([
|
writer.writerow([
|
||||||
txn.date.strftime('%Y-%m-%d'),
|
txn.date.strftime('%Y-%m-%d'),
|
||||||
txn.transaction_type,
|
txn.transaction_type,
|
||||||
@@ -27,80 +39,87 @@ def transactions_to_csv(transactions):
|
|||||||
float(txn.amount),
|
float(txn.amount),
|
||||||
txn.notes or '',
|
txn.notes or '',
|
||||||
])
|
])
|
||||||
|
yield buf.getvalue()
|
||||||
output.seek(0)
|
buf.seek(0); buf.truncate()
|
||||||
return output.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Excel export ──────────────────────────────────────────────────────────────
|
# ── Excel export ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def transactions_to_excel(transactions, period_label='Transactions'):
|
def transactions_to_excel(query, period_label='Transactions'):
|
||||||
"""Return Excel bytes for a list of 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 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
|
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', '$')
|
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_fill = PatternFill(start_color='0F172A', end_color='0F172A', fill_type='solid')
|
||||||
header_font = Font(color='F1F5F9', bold=True, size=10)
|
header_font = Font(color='F1F5F9', bold=True, size=10)
|
||||||
thin = Side(style='thin', color='E2E8F0')
|
headers = ['Date', 'Type', 'Description', 'Category', 'Account',
|
||||||
border = Border(bottom=Side(style='thin', color='E2E8F0'))
|
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')
|
income_fill = PatternFill(start_color='F0FDF4', end_color='F0FDF4', fill_type='solid')
|
||||||
expense_fill = PatternFill(start_color='FFF7F7', end_color='FFF7F7', 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
|
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.date.strftime('%Y-%m-%d'),
|
||||||
txn.transaction_type.title(),
|
txn.transaction_type.title(),
|
||||||
txn.description,
|
txn.description,
|
||||||
txn.category.name if txn.category else '',
|
txn.category.name if txn.category else '',
|
||||||
txn.account.name if txn.account else '',
|
txn.account.name if txn.account else '',
|
||||||
float(txn.amount),
|
amt,
|
||||||
txn.notes or '',
|
txn.notes or '',
|
||||||
]
|
]
|
||||||
for col, value in enumerate(data, 1):
|
row = []
|
||||||
cell = ws.cell(row=row_num, column=col, value=value)
|
for col_idx, value in enumerate(row_vals, 1):
|
||||||
cell.fill = fill
|
c = WriteOnlyCell(ws, value=value)
|
||||||
cell.border = border
|
c.font = row_font
|
||||||
cell.font = Font(size=10)
|
c.fill = fill
|
||||||
if col == 6:
|
if col_idx == 6:
|
||||||
cell.number_format = f'#,##0.00'
|
c.number_format = amt_fmt
|
||||||
cell.alignment = Alignment(horizontal='right')
|
c.alignment = Alignment(horizontal='right')
|
||||||
|
row.append(c)
|
||||||
|
ws.append(row)
|
||||||
|
|
||||||
# Totals row
|
# Totals row — plain cells (write-only, no random access)
|
||||||
total_row = len(transactions) + 2
|
total_lbl = WriteOnlyCell(ws, value='TOTAL')
|
||||||
ws.cell(row=total_row, column=5, value='TOTAL').font = Font(bold=True, size=10)
|
total_lbl.font = Font(bold=True, size=10)
|
||||||
total_cell = ws.cell(row=total_row, column=6,
|
total_val = WriteOnlyCell(ws, value=running_total)
|
||||||
value=sum(float(t.amount) for t in transactions))
|
total_val.font = Font(bold=True, size=10)
|
||||||
total_cell.font = Font(bold=True, size=10)
|
total_val.number_format = amt_fmt
|
||||||
total_cell.number_format = f'#,##0.00'
|
total_val.alignment = Alignment(horizontal='right')
|
||||||
total_cell.alignment = Alignment(horizontal='right')
|
ws.append(['', '', '', '', total_lbl, total_val, ''])
|
||||||
|
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
wb.save(output)
|
wb.save(output)
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
return output.getvalue()
|
return output.read()
|
||||||
|
|
||||||
|
|
||||||
# ── PDF export ────────────────────────────────────────────────────────────────
|
# ── 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
|
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():
|
def get_portfolio_summary():
|
||||||
"""
|
"""
|
||||||
Return portfolio-level aggregates across all active investments.
|
Return portfolio-level aggregates across all active investments.
|
||||||
|
|||||||
@@ -217,12 +217,6 @@ def sync_transactions(item):
|
|||||||
return added, modified, removed, cursor
|
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):
|
def _map_plaid_category(plaid_cats, txn_type):
|
||||||
"""Map Plaid category array to a PFM category name."""
|
"""Map Plaid category array to a PFM category name."""
|
||||||
if not plaid_cats:
|
if not plaid_cats:
|
||||||
@@ -292,6 +286,7 @@ def sync_preview(item):
|
|||||||
added, _modified, _removed, next_cursor = sync_transactions(item)
|
added, _modified, _removed, next_cursor = sync_transactions(item)
|
||||||
|
|
||||||
# Build maps
|
# 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_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}
|
plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts}
|
||||||
cat_map = build_category_map()
|
cat_map = build_category_map()
|
||||||
@@ -324,9 +319,17 @@ def import_transactions(parsed_txns, next_cursor, item):
|
|||||||
affected_accounts = set()
|
affected_accounts = set()
|
||||||
plaid_account_ids_synced = 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:
|
for p in parsed_txns:
|
||||||
pid = p['plaid_id']
|
if p['notes'] in existing_notes:
|
||||||
if Transaction.query.filter(Transaction.notes.like(f'%Plaid:{pid}%')).first():
|
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -344,18 +347,13 @@ def import_transactions(parsed_txns, next_cursor, item):
|
|||||||
plaid_account_ids_synced.add(p['plaid_account_id'])
|
plaid_account_ids_synced.add(p['plaid_account_id'])
|
||||||
imported += 1
|
imported += 1
|
||||||
|
|
||||||
db.session.commit()
|
# Single commit — transactions + cursor + metadata together
|
||||||
|
|
||||||
# Advance cursor
|
|
||||||
item.cursor = next_cursor
|
item.cursor = next_cursor
|
||||||
item.last_synced_at = datetime.utcnow()
|
item.last_synced_at = datetime.utcnow()
|
||||||
|
|
||||||
# Update last_sync_date per PlaidAccount
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
for pa in PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all():
|
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:
|
if pa.plaid_account_id in plaid_account_ids_synced:
|
||||||
pa.last_sync_date = today
|
pa.last_sync_date = today
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Refresh balances for mapped accounts
|
# Refresh balances for mapped accounts
|
||||||
@@ -441,6 +439,7 @@ def auto_sync_item(item):
|
|||||||
|
|
||||||
added, modified, removed, next_cursor = sync_transactions(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_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}
|
plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts}
|
||||||
cat_map = build_category_map()
|
cat_map = build_category_map()
|
||||||
@@ -456,15 +455,12 @@ def auto_sync_item(item):
|
|||||||
|
|
||||||
imported, skipped = import_transactions(parsed, next_cursor, 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
|
removed_count = 0
|
||||||
if removed:
|
if removed:
|
||||||
for r in removed:
|
remove_notes = {f'Plaid:{r.get("transaction_id", "")}' for r in removed if r.get('transaction_id')}
|
||||||
tid = r.get('transaction_id', '')
|
txns_to_delete = Transaction.query.filter(Transaction.notes.in_(remove_notes)).all()
|
||||||
txn = Transaction.query.filter(
|
for txn in txns_to_delete:
|
||||||
Transaction.notes.like(f'%Plaid:{tid}%')
|
|
||||||
).first()
|
|
||||||
if txn:
|
|
||||||
db.session.delete(txn)
|
db.session.delete(txn)
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
if removed_count:
|
if removed_count:
|
||||||
|
|||||||
@@ -94,6 +94,104 @@ def process_due_rules(dry_run=False):
|
|||||||
return created
|
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):
|
def get_upcoming(days=30):
|
||||||
"""Return list of upcoming recurring transactions in the next N days."""
|
"""Return list of upcoming recurring transactions in the next N days."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ net worth history, category trends, and tax year reports.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import calendar
|
import calendar
|
||||||
from datetime import date, datetime
|
from collections import defaultdict
|
||||||
from sqlalchemy import func
|
from datetime import date, datetime, timedelta
|
||||||
|
from sqlalchemy import func, extract
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
@@ -172,18 +173,48 @@ def yearly_report(year):
|
|||||||
# ── Net worth history ─────────────────────────────────────────────────────────
|
# ── Net worth history ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def net_worth_history():
|
def net_worth_history():
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
snapshots = NetWorthSnapshot.query\
|
snapshots = NetWorthSnapshot.query\
|
||||||
.order_by(NetWorthSnapshot.snapshot_date.asc())\
|
.order_by(NetWorthSnapshot.snapshot_date.asc())\
|
||||||
.all()
|
.all()
|
||||||
return {
|
|
||||||
|
result = {
|
||||||
'snapshots': snapshots,
|
'snapshots': snapshots,
|
||||||
'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots],
|
'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots],
|
||||||
'values': [float(s.net_worth) for s in snapshots],
|
'values': [float(s.net_worth) for s in snapshots],
|
||||||
'assets': [float(s.total_assets) for s in snapshots],
|
'assets': [float(s.total_assets) for s in snapshots],
|
||||||
'liabilities': [float(s.total_liabilities) for s in snapshots],
|
'liabilities': [float(s.total_liabilities) for s in snapshots],
|
||||||
'count': len(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) ──────────────────────────────────
|
# ── 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 ──────────────────────────────────────────────────────────
|
# ── Snapshot helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def take_net_worth_snapshot():
|
def take_net_worth_snapshot():
|
||||||
|
|||||||
@@ -336,12 +336,6 @@ def get_transactions(connection, account_hash, start_date, end_date):
|
|||||||
return data
|
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):
|
def parse_transaction(schwab_txn, pfm_account_id, cat_id_map):
|
||||||
"""
|
"""
|
||||||
Convert a Schwab transaction dict to a PFM-ready dict.
|
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,
|
end_date=today,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.services.bank_import_service import build_category_map
|
||||||
cat_map = build_category_map()
|
cat_map = build_category_map()
|
||||||
return [
|
return [
|
||||||
parse_transaction(t, schwab_account.pfm_account_id, cat_map)
|
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.extensions import db
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.services.account_service import calc_balance
|
|
||||||
|
|
||||||
imported = skipped = 0
|
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:
|
for p in parsed_txns:
|
||||||
sid = p['schwab_id']
|
if p['notes'] in existing_notes:
|
||||||
if Transaction.query.filter(Transaction.notes.like(f'%Schwab:{sid}%')).first():
|
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -448,19 +449,15 @@ def import_transactions(parsed_txns, schwab_account):
|
|||||||
date = p['date'],
|
date = p['date'],
|
||||||
notes = p['notes'],
|
notes = p['notes'],
|
||||||
))
|
))
|
||||||
if p['account_id']:
|
|
||||||
affected.add(p['account_id'])
|
|
||||||
imported += 1
|
imported += 1
|
||||||
|
|
||||||
db.session.commit()
|
# Single commit — transactions + metadata together
|
||||||
|
|
||||||
schwab_account.last_sync_date = date.today()
|
schwab_account.last_sync_date = date.today()
|
||||||
schwab_account.connection.last_synced_at = datetime.utcnow()
|
schwab_account.connection.last_synced_at = datetime.utcnow()
|
||||||
if parsed_txns:
|
if parsed_txns:
|
||||||
schwab_account.last_schwab_txn_id = parsed_txns[0]['schwab_id']
|
schwab_account.last_schwab_txn_id = parsed_txns[0]['schwab_id']
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
# Balance for Schwab accounts is set by sync_account_snapshot (liquidationValue),
|
||||||
for acct_id in affected:
|
# not by summing transactions — skip calc_balance here.
|
||||||
calc_balance(acct_id)
|
|
||||||
|
|
||||||
return imported, skipped
|
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):
|
def sync_preview(teller_account, days_back=90):
|
||||||
"""
|
"""
|
||||||
Fetch transactions for a TellerAccount and return a preview list.
|
Fetch transactions for a TellerAccount and return a preview list.
|
||||||
@@ -272,6 +265,7 @@ def sync_preview(teller_account, days_back=90):
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
from app.services.bank_import_service import build_category_map
|
||||||
cat_map = build_category_map()
|
cat_map = build_category_map()
|
||||||
is_cc = (
|
is_cc = (
|
||||||
teller_account.account_type == 'credit' or
|
teller_account.account_type == 'credit' or
|
||||||
@@ -302,37 +296,38 @@ def import_transactions(parsed_txns, teller_account):
|
|||||||
skipped = 0
|
skipped = 0
|
||||||
affected_accounts = set()
|
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:
|
for p in parsed_txns:
|
||||||
# Duplicate check: match on teller_id in notes
|
note_str = f'Teller:{p["teller_id"]}'
|
||||||
teller_id = p['teller_id']
|
if note_str in existing_notes:
|
||||||
existing = Transaction.query.filter(
|
|
||||||
Transaction.notes.like(f'%{teller_id}%')
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
txn = Transaction(
|
db.session.add(Transaction(
|
||||||
account_id=p['account_id'],
|
account_id = p['account_id'],
|
||||||
category_id=p.get('category_id'),
|
category_id = p.get('category_id'),
|
||||||
transaction_type=p['transaction_type'],
|
transaction_type = p['transaction_type'],
|
||||||
amount=p['amount'],
|
amount = p['amount'],
|
||||||
description=p['description'],
|
description = p['description'],
|
||||||
date=p['date'],
|
date = p['date'],
|
||||||
notes=f"Teller:{teller_id}",
|
notes = note_str,
|
||||||
)
|
))
|
||||||
db.session.add(txn)
|
|
||||||
if p['account_id']:
|
if p['account_id']:
|
||||||
affected_accounts.add(p['account_id'])
|
affected_accounts.add(p['account_id'])
|
||||||
imported += 1
|
imported += 1
|
||||||
|
|
||||||
db.session.commit()
|
# Single commit — transactions + sync metadata together
|
||||||
|
|
||||||
# Update sync metadata on both the account and its parent enrollment
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
now = datetime.utcnow()
|
|
||||||
teller_account.last_sync_date = date.today()
|
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:
|
if parsed_txns:
|
||||||
teller_account.last_teller_txn_id = parsed_txns[0]['teller_id']
|
teller_account.last_teller_txn_id = parsed_txns[0]['teller_id']
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -197,6 +197,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) #}
|
{# Parse errors (non-fatal) #}
|
||||||
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
|
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
|
||||||
<strong>Warnings</strong> — these rows were skipped:<br>
|
<strong>Warnings</strong> — these rows were skipped:<br>
|
||||||
@@ -580,31 +591,56 @@
|
|||||||
setImportLoading(true);
|
setImportLoading(true);
|
||||||
$('import-error').classList.add('d-none');
|
$('import-error').classList.add('d-none');
|
||||||
|
|
||||||
fetch('{{ url_for("bank_import.confirm_import") }}', {
|
const CHUNK = 50;
|
||||||
method: 'POST',
|
const useChunks = rows.length > CHUNK;
|
||||||
headers: {
|
let totalImported = 0, totalSkipped = 0;
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': CSRF,
|
function setProgress(done, total) {
|
||||||
},
|
const pct = Math.round(done / total * 100);
|
||||||
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows }),
|
$('import-progress-bar').style.width = pct + '%';
|
||||||
})
|
$('import-progress-pct').textContent = pct + '%';
|
||||||
.then(r => r.json())
|
$('import-progress-label').textContent = `Importing… ${done} of ${total}`;
|
||||||
.then(data => {
|
|
||||||
setImportLoading(false);
|
|
||||||
if (data.error) {
|
|
||||||
$('import-error').textContent = data.error;
|
|
||||||
$('import-error').classList.remove('d-none');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 =
|
$('done-title').textContent =
|
||||||
data.imported + ' transaction' + (data.imported !== 1 ? 's' : '') + ' imported successfully';
|
totalImported + ' transaction' + (totalImported !== 1 ? 's' : '') + ' imported successfully';
|
||||||
$('done-subtitle').textContent =
|
$('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');
|
showStep('step-done');
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
setImportLoading(false);
|
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');
|
$('import-error').classList.remove('d-none');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+221
-16
@@ -169,7 +169,10 @@
|
|||||||
.table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
.table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
/* Remove h-100 height constraint on table wrappers so overflow-x works */
|
/* Remove h-100 height constraint on table wrappers so overflow-x works */
|
||||||
.pcard.p-0.h-100 { height: auto !important; }
|
.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 */
|
/* Hide low-priority columns */
|
||||||
.d-mob-none { display: none !important; }
|
.d-mob-none { display: none !important; }
|
||||||
/* Topbar title: truncate so action buttons always fit */
|
/* Topbar title: truncate so action buttons always fit */
|
||||||
@@ -186,13 +189,103 @@
|
|||||||
.btn-label { display: none; }
|
.btn-label { display: none; }
|
||||||
.tb-right .btn { padding-left: 8px; padding-right: 8px; }
|
.tb-right .btn { padding-left: 8px; padding-right: 8px; }
|
||||||
.stat-card .stat-value { font-size: 16px; }
|
.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 { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; }
|
||||||
.sb-overlay.on { display: block; }
|
.sb-overlay.on { display: block; }
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@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 %}
|
{% block extra_css %}{% endblock %}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="sb-overlay" id="sbOverlay"></div>
|
<div class="sb-overlay" id="sbOverlay"></div>
|
||||||
@@ -217,21 +310,11 @@
|
|||||||
class="sb-link {% if request.blueprint == 'transactions' %}active{% endif %}"
|
class="sb-link {% if request.blueprint == 'transactions' %}active{% endif %}"
|
||||||
>
|
>
|
||||||
<i class="bi bi-arrow-left-right"></i
|
<i class="bi bi-arrow-left-right"></i
|
||||||
><span class="lt">Transactions</span>
|
><span class="lt">Transactions
|
||||||
</a>
|
{% if plaid_review_count > 0 %}
|
||||||
<a
|
<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>
|
||||||
href="{{ url_for('transactions.new', type='income') }}"
|
{% endif %}
|
||||||
class="sb-link"
|
</span>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('accounts.index') }}"
|
href="{{ url_for('accounts.index') }}"
|
||||||
@@ -311,6 +394,17 @@
|
|||||||
<span class="tb-title">{% block page_title %}{% endblock %}</span>
|
<span class="tb-title">{% block page_title %}{% endblock %}</span>
|
||||||
<div class="tb-right">
|
<div class="tb-right">
|
||||||
{% block topbar_actions %}{% endblock %}
|
{% 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"
|
<span class="d-none d-sm-inline small text-muted mono"
|
||||||
>{{ current_user.display_name or current_user.username }}</span
|
>{{ current_user.display_name or current_user.username }}</span
|
||||||
>
|
>
|
||||||
@@ -339,6 +433,27 @@
|
|||||||
<!-- MAIN -->
|
<!-- MAIN -->
|
||||||
<main id="main">{% block content %}{% endblock %}</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 src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -372,6 +487,96 @@
|
|||||||
}, 4500);
|
}, 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>
|
</script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -58,6 +58,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- Budget Items -->
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
<div class="pcard p-0">
|
<div class="pcard p-0">
|
||||||
@@ -148,3 +159,74 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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>
|
||||||
</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 -->
|
<!-- Charts Row -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12 col-xl-8">
|
<div class="col-12 col-xl-8">
|
||||||
@@ -238,6 +271,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- Accounts + Recent Transactions -->
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-lg-4">
|
<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>
|
<a href="{{ url_for('transactions.index') }}" style="font-size:12px;color:#3b82f6;">View all</a>
|
||||||
</div>
|
</div>
|
||||||
{% if recent_txns %}
|
{% if recent_txns %}
|
||||||
|
<div class="table-wrap">
|
||||||
<table class="pfm-table">
|
<table class="pfm-table">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for txn in recent_txns %}
|
{% for txn in recent_txns %}
|
||||||
@@ -299,7 +342,7 @@
|
|||||||
<div style="font-size:13px;font-weight:500;">{{ txn.description }}</div>
|
<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>
|
<div style="font-size:11px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="d-mob-none">
|
||||||
{% if txn.category %}
|
{% if txn.category %}
|
||||||
<span style="font-size:12px;"><i class="bi {{ txn.category.icon }}" style="color:{{ txn.category.color }};"></i> {{ txn.category.name }}</span>
|
<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 %}
|
{% else %}<span class="text-muted" style="font-size:12px;">—</span>{% endif %}
|
||||||
@@ -311,6 +354,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted small">No transactions yet. <a href="{{ url_for('transactions.new', type='expense') }}">Add one</a>.</p>
|
<p class="text-muted small">No transactions yet. <a href="{{ url_for('transactions.new', type='expense') }}">Add one</a>.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -318,6 +362,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Custom Range Modal -->
|
||||||
<div class="modal fade" id="customModal" tabindex="-1">
|
<div class="modal fade" id="customModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-sm">
|
<div class="modal-dialog modal-sm">
|
||||||
@@ -345,6 +445,50 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||||
<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(){
|
(function(){
|
||||||
const ctx = document.getElementById('cashflowChart').getContext('2d');
|
const ctx = document.getElementById('cashflowChart').getContext('2d');
|
||||||
new Chart(ctx, {
|
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(){
|
function toggleFxChart(){
|
||||||
const wrap = document.getElementById('fxChartWrap');
|
const wrap = document.getElementById('fxChartWrap');
|
||||||
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
|
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
{% if completed_goals %}
|
{% if completed_goals %}
|
||||||
<div class="pcard">
|
<div class="pcard">
|
||||||
<div class="pcard-title mb-3">Completed Goals 🎉</div>
|
<div class="pcard-title mb-3">Completed Goals 🎉</div>
|
||||||
|
<div class="table-wrap">
|
||||||
<table class="pfm-table">
|
<table class="pfm-table">
|
||||||
<thead><tr><th>Goal</th><th>Target</th><th>Completed</th></tr></thead>
|
<thead><tr><th>Goal</th><th>Target</th><th>Completed</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -137,6 +138,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -78,6 +78,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 -->
|
<!-- Summary cards -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-6 col-xl-3">
|
<div class="col-6 col-xl-3">
|
||||||
@@ -134,7 +142,8 @@
|
|||||||
|
|
||||||
{# ── Reusable holdings table macro ──────────────────────────────────────── #}
|
{# ── Reusable holdings table macro ──────────────────────────────────────── #}
|
||||||
{% macro holdings_table(inv_list) %}
|
{% macro holdings_table(inv_list) %}
|
||||||
<table class="pfm-table">
|
<div class="table-wrap">
|
||||||
|
<table class="pfm-table wide">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="padding-left:20px;">Asset</th>
|
<th style="padding-left:20px;">Asset</th>
|
||||||
@@ -219,6 +228,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<!-- Chart + Allocation -->
|
<!-- 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>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -134,12 +134,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Net worth history -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="pcard">
|
<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>
|
<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">
|
<div class="d-flex gap-2">
|
||||||
<form method="POST" action="{{ url_for('reports.manual_snapshot') }}">
|
<form method="POST" action="{{ url_for('reports.manual_snapshot') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<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 %}
|
{% if nw_history.count > 1 %}
|
||||||
(function(){
|
(function(){
|
||||||
const ctx = document.getElementById('nwChart').getContext('2d');
|
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, {
|
new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: {{ nw_history.labels | tojson }},
|
labels: allLabels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label:'Net Worth', data: {{ nw_history['values'] | tojson }}, borderColor:'#3b82f6', backgroundColor:'#3b82f611', borderWidth:2, pointRadius:3, tension:.3, fill:true },
|
{ 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: {{ nw_history.assets | tojson }}, borderColor:'#10b981', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[4,3] },
|
{ 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 } } } } }
|
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>
|
<small class="text-muted" style="font-size:11px;">AI model used for chat and daily insights.</small>
|
||||||
</div>
|
</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") }}
|
{{ form.submit(class="btn btn-primary") }}
|
||||||
<a href="{{ url_for('settings.password') }}" class="btn btn-outline-secondary ms-2">Change Password</a>
|
<a href="{{ url_for('settings.password') }}" class="btn btn-outline-secondary ms-2">Change Password</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -94,4 +94,145 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -120,6 +120,13 @@
|
|||||||
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes", id="field_notes") }}
|
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes", id="field_notes") }}
|
||||||
</div>
|
</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">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" id="submitBtn"
|
<button type="submit" id="submitBtn"
|
||||||
class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
|
class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
|
||||||
@@ -426,5 +433,39 @@ function setTxnType(type) {
|
|||||||
sel.value = prevVal;
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,6 +9,16 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 -->
|
<!-- Tabs -->
|
||||||
<div class="d-flex gap-1 mb-3">
|
<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) }}"
|
<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 -->
|
<!-- Filters -->
|
||||||
<div class="pcard pcard-sm mb-3">
|
<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 }}">
|
<input type="hidden" name="tab" value="{{ tab }}">
|
||||||
|
<!-- Row 1: main filters -->
|
||||||
<div class="col-12 col-md-3">
|
<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>
|
||||||
<div class="col-6 col-md-2">
|
<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>
|
<option value="">All categories</option>
|
||||||
{% for cat in categories %}
|
{% for cat in categories %}
|
||||||
<option value="{{ cat.id }}" {% if category_id == cat.id|string %}selected{% endif %}>{{ cat.name }}</option>
|
<option value="{{ cat.id }}" {% if category_id == cat.id|string %}selected{% endif %}>{{ cat.name }}</option>
|
||||||
@@ -57,7 +68,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-2">
|
<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>
|
<option value="">All accounts</option>
|
||||||
{% for acct in accounts %}
|
{% for acct in accounts %}
|
||||||
<option value="{{ acct.id }}" {% if account_id == acct.id|string %}selected{% endif %}>{{ acct.name }}</option>
|
<option value="{{ acct.id }}" {% if account_id == acct.id|string %}selected{% endif %}>{{ acct.name }}</option>
|
||||||
@@ -65,14 +76,43 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-2">
|
<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>
|
||||||
<div class="col-6 col-md-2">
|
<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>
|
||||||
<div class="col-12 col-md-1 d-flex gap-1">
|
<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>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +193,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
|
<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.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?')">
|
<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() }}">
|
<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>
|
<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.'));
|
.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>
|
</script>
|
||||||
{% endblock %}
|
{% 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,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,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__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from app import create_app
|
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()
|
app = create_app()
|
||||||
|
|
||||||
@@ -23,3 +23,7 @@ if __name__ == '__main__':
|
|||||||
print(f'[fetch_prices] Updated {len(updated)} ticker(s).')
|
print(f'[fetch_prices] Updated {len(updated)} ticker(s).')
|
||||||
else:
|
else:
|
||||||
print('[fetch_prices] No tickers updated.')
|
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