Compare commits

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