06/05 Optimize app: Add Plaid web hook, healthcheck

This commit is contained in:
2026-06-05 13:57:36 -04:00
parent 452374365c
commit 2f62417c9f
9 changed files with 449 additions and 44 deletions
+1
View File
@@ -13,3 +13,4 @@ FLASK_APP=wsgi:app
PLAID_CLIENT_ID=your-plaid-client-id
PLAID_SECRET=your-plaid-secret
PLAID_ENV=sandbox
PLAID_WEBHOOK_URL=https://pfm.ngodanguyen.tech/plaid/webhook
+122 -40
View File
@@ -7,7 +7,7 @@
## 1. Project Overview
Self-hosted personal finance web app. Tracks income, expenses, investments. AI assistant powered by **Groq API** (free tier, fast inference, no local hardware). Receipt OCR via Groq vision model. Bank account sync via Teller API (mTLS) and Schwab Developer API (OAuth 2.0). Bank statement import (CSV, OFX/QFX, PDF). Everything runs on Ubuntu server behind Nginx + Certbot SSL.
Self-hosted personal finance web app. Tracks income, expenses, investments. AI assistant powered by **Groq API** (free tier, fast inference, no local hardware). Receipt OCR via Groq vision model. Bank account sync via Teller API (mTLS), Schwab Developer API (OAuth 2.0), and Plaid API. Bank statement import (CSV, OFX/QFX, PDF). Everything runs on Ubuntu server behind Nginx + Certbot SSL.
**Status: All 7 phases complete + all post-MVP features implemented.**
@@ -30,16 +30,24 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- Stale indicator if rate > 1 day old
- Period selector: This Month / Last Month / Custom date range
- Credit card accounts display "owed" balance (positive Amount Owed) not raw negative
- **Checking & Savings card** — sum of balances for `checking`, `savings`, `cash` account types
- **Investments card** — sum of balances for `investment`, `crypto` account types
- **Reconcile button** — AJAX `GET /api/reconcile`; excludes transactions in any category whose name contains "transfer" (case-insensitive); updates Income / Expenses / Net Cash Flow / Savings Rate cards in-place; toggles back to original; shows notice with excluded amounts and category names
### 2.2 Transactions
- Income + Expense entry with Income/Expense tabs
- Transfer between accounts
- Filter: search, category, account, date range (safe int parsing — no crash on bad params)
- **Quick date filters** — "This Month" and "Last Month" buttons above the filter bar; active button highlighted; ✕ clear button shown when a quick filter is active
- Pagination (30/page)
- Receipt upload (PNG/JPG/WEBP/GIF/PDF, max 10MB)
- **AI Receipt OCR** — drag-drop receipt image → Groq vision extracts amount/date/merchant/category → auto-fills form
- Re-extract from already-uploaded receipt (edit mode)
- **Inline category change** — Category column is a `<select>` dropdown; change fires AJAX `POST /transactions/<id>/set-category` with no page reload
- **Bulk actions** — checkbox per row + select-all header checkbox; sticky dark toolbar appears when rows are checked; supports:
- **Bulk delete** — confirmation dialog; rows removed from DOM after AJAX delete
- **Bulk set category** — dropdown + Apply; inline category selects updated in DOM without reload
- AJAX endpoint: `POST /transactions/bulk-action` with `{action, ids, category_id}`
- Export to CSV / Excel
- Edit form: receipt sub-forms are outside `#txnForm` to prevent nested-form bug
@@ -48,12 +56,15 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- Balance source of truth:
- **Teller-linked accounts**: balance comes from Teller API (live refresh or after sync); `calc_balance` is NOT called on page load for these
- **Schwab-linked accounts**: balance comes from Schwab snapshot sync; `calc_balance` is NOT called on page load for these
- **Plaid-linked accounts**: balance comes from Plaid API after sync or Refresh button; `calc_balance` is NOT called on page load for these
- **Unlinked accounts**: balance auto-calculated from all transactions via `calc_balance`
- **Teller badge** (blue) shown on account cards linked to Teller
- **Schwab badge** (green) shown on account cards linked to Schwab
- **Plaid badge** (purple) shown on account cards linked to Plaid; credit card billing card shown (due date, days left, min payment, statement balance)
- Per-account action buttons for provider-linked accounts:
- **Teller**: Refresh (live balance AJAX), Sync (transaction preview), Reset (90-day resync)
- **Schwab**: Balance & Positions (snapshot sync POST), Transactions (preview link)
- **Plaid**: Refresh (live balance AJAX), Sync (→ sync preview), Billing (POST liabilities refresh)
- Color + icon picker; soft delete
- Credit cards show "Amount Owed" (positive) and "This Month" charges
- Opening balance field on account creation (negative for credit cards = starting debt)
@@ -128,11 +139,12 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- 8 currency options (USD/VND/EUR/GBP/JPY/AUD/CAD/SGD) — auto-updates symbol
- Password change (requires current password)
- **Two-Factor Authentication (TOTP)** — enable/disable TOTP 2FA; setup shows QR code + manual key entry; disable requires password confirmation
- **Audit Log** (`/settings/audit`) — paginated log of login, 2FA, password, and bank-connection events with IP address; filterable by event type
- **Audit Log** (`/settings/audit`) — paginated log of login, 2FA, password, and bank-connection events with IP address; filterable by event type; **Purge** dropdown (7 / 30 / 90 days) via `POST /settings/audit/purge`
- Recurring rules: CRUD, pause/enable, frequency (daily/weekly/biweekly/monthly/quarterly/yearly)
- "Run Now" button to process due rules immediately
- CSV import: upload → preview with ⚠ warnings → confirm
- Upcoming recurring transactions (30-day view)
- **All Settings sub-pages have a `← Settings` back button** in the topbar (Teller, Schwab, Plaid, Audit Log, Profile, Password, Recurring, Import, System Logs, Recurring Form)
### 2.12 USD → VND Exchange Rate
- **Reference widget only** — not used in transaction calculations
@@ -146,7 +158,7 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
### 2.13 Teller Bank Sync
- Connects US bank accounts via Teller API (mTLS + HTTP Basic Auth)
- **Bank Connections page** (`/teller/`) — connect/disconnect only; no sync buttons here
- **Bank Connections page** (`/teller/`) — accessible via Settings; connect/disconnect only; no sync buttons here
- Shows: institution name, connected date, last synced, account list with linked PFM account names
- "Map Account" button for unmapped accounts
- **Accounts page** — all Teller action buttons live here per account card (see §2.3)
@@ -159,9 +171,11 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- Webhook: `transactions.processed` event with HMAC-SHA256 signature + 5-min replay protection
- Config: `TELLER_APP_ID`, `TELLER_ENV`, `TELLER_CERT_PATH`, `TELLER_KEY_PATH`, `TELLER_WEBHOOK_SECRET`
- Models: `teller_enrollments`, `teller_accounts` (2 tables)
- **Not in sidebar** — accessed via Settings page only
### 2.14 Schwab Bank Sync
- Connects Schwab brokerage/IRA accounts via Schwab Developer API (OAuth 2.0)
- **Accessible via Settings page only** — removed from sidebar
- **OAuth flow**: `GET /schwab/connect` → redirect to Schwab with PKCE state → `GET /schwab/callback` → exchange code → store tokens
- State parameter included in auth URL (fix for "OAuth state mismatch" error)
- `SCHWAB_REDIRECT_URI` must match exactly what's registered in Schwab developer portal
@@ -189,20 +203,42 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- Sidebar link: "Import Statement" under Money section
- Supported formats: PDF, OFX/QFX, Chase/BofA/Citi/Capital One/Discover/Amex/USAA/Wells Fargo CSV, Generic CSV, Custom column mapping
- Auto-categorizes using 200+ keyword rules across 14 categories
- Preview table: per-row checkboxes, editable category dropdowns
- Preview table: per-row checkboxes, editable category dropdowns, bulk type toggle + bulk category apply
- Duplicate detection: OFX FITID (`import:<id>` in notes) or date+amount+description+account
- AJAX-based: no page reloads
### 2.16 System Logs Viewer
- Log file: `logs/app.log` (rotating, 10 MB, 5 backups)
- Log file: `logs/app.log` (rotating, 10 MB, 5 backups) — kept for download/backup
- **DB-backed viewer** — log entries also written to `app_logs` table via `DBLogHandler`; viewer queries DB (not file)
- Format: `YYYY-MM-DD HH:MM:SS|LEVEL|module.name|message`
- Viewer at `/logs/`: colour-coded pills, free-text search, module filter, auto-refresh, clear, download
- Viewer at `/logs/`: colour-coded pills, free-text search, module filter, auto-refresh, download (file), clear all (DB + file)
- **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
- 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
- **Environments**: `sandbox` and `production` only — `development` was sunset by Plaid; old configs that set `development` fall back to `production`
- **Credit card liabilities**: `POST /plaid/liabilities/<item_db_id>` fetches due date, minimum payment, last statement balance, is_overdue via `/liabilities/get`; shown on both Plaid page and Accounts page
- **Transaction sync**: cursor-based (`/transactions/sync`); preview → confirm → import; cursor stored at item level in `plaid_items.cursor`; pending transactions skipped
- **Reset sync** (`POST /plaid/resync/<item_db_id>`) — clears `cursor` and `last_sync_date` so next sync re-fetches full available history; duplicates skipped automatically via `Plaid:<id>` in notes
- **Balance refresh** (AJAX `POST /plaid/balance/<pa_db_id>`) — live balance from `/accounts/balance/get`; credit cards stored as negative (debt convention)
- **Duplicate detection**: `Plaid:<transaction_id>` in notes
- **Sign convention**: positive Plaid amount = expense (outflow), negative = income (inflow) — same for ALL account types
- **Auto-categorize**: keyword match on description first; Plaid top-level category as fallback
- Config: `PLAID_CLIENT_ID`, `PLAID_SECRET`, `PLAID_ENV` (sandbox / production), `PLAID_WEBHOOK_URL`
- Models: `plaid_items`, `plaid_accounts`, `plaid_sync_previews` (3 tables)
- **Webhook** (`POST /plaid/webhook`) — CSRF-exempt; verified via Plaid JWT (ES256, rotating JWK from `/webhook_verification_key/get`); handles `TRANSACTIONS/*` events by auto-importing without preview; handles `ITEM/ERROR` with logging; requires `PyJWT` package
- **Auto-sync** — `plaid_service.auto_sync_item(item)` runs cursor sync + silent import; also deletes transactions Plaid marks removed; used by webhook handler
- **Update webhook for existing items** (`POST /plaid/update-webhook`) — calls Plaid `/item/webhook/update` for all active items; "Apply to Existing Items" button shown on Plaid page when URL is configured
- **Not in sidebar** — accessed via Settings page only
---
## 3. Database Schema (MySQL)
### All 19 Tables
### All 23 Tables
```
users — single user, hashed password, currency/timezone prefs, totp_secret, totp_enabled
accounts — bank/wallet accounts (balance managed per provider rules)
@@ -219,26 +255,35 @@ net_worth_snapshots — monthly snapshots: assets, liabilities, net_worth (J
ai_insights — stored AI responses: daily_summary / chat_response
fx_rates — daily USD/VND rate cache (date UNIQUE, source)
audit_logs — security event log: action, description, ip_address, timestamp
app_logs — application log mirror: timestamp, level, module, message (TEXT)
teller_enrollments — Teller enrollment: enrollment_id, access_token (TEXT, encrypted), institution_name
teller_accounts — Teller account ↔ PFM account mapping, last_sync_date
schwab_connections — Schwab OAuth tokens (TEXT, encrypted), token_expires_at, refresh_token_expires_at
schwab_accounts — Schwab account ↔ PFM account mapping, account_hash (hashValue)
plaid_items — Plaid item: item_id, access_token (EncryptedText), institution_name, cursor, last_synced_at
plaid_accounts — Plaid account ↔ PFM account mapping; cc_due_date, cc_minimum_payment, cc_last_statement_balance, cc_is_overdue
plaid_sync_previews — temporary preview data: item_id (UNIQUE), data_json (TEXT), next_cursor
```
### Key Column Notes
- `accounts.balance` — set by `calc_balance()` for unlinked accounts; set directly by Teller/Schwab sync for provider-linked accounts; never overwritten on page load for provider accounts
- `accounts.balance` — set by `calc_balance()` for unlinked accounts; set directly by Teller/Schwab/Plaid sync for provider-linked accounts; never overwritten on page load for provider accounts
- `investments.account_id` — nullable FK to `accounts.id`; NULL for manually-added holdings, set to PFM account ID for Schwab-synced holdings; enables per-account grouping on portfolio page
- `investments.shares` / `avg_cost_basis` — recalculated from `investment_transactions` (FIFO) for manual holdings; overwritten directly by Schwab snapshot for synced holdings
- `transactions.notes` — used to store import source IDs: `Teller:<id>`, `Schwab:<activityId>`, or `import:<fitid>`
- `transactions.notes` — used to store import source IDs: `Teller:<id>`, `Schwab:<activityId>`, `Plaid:<transaction_id>`, or `import:<fitid>`
- `schwab_accounts.account_hash` — Schwab `hashValue` (encrypted account number), required in all API paths
- `teller_enrollments.access_token` — stored as `TEXT` (widened from VARCHAR(128)); encrypted at rest via `EncryptedText` TypeDecorator
- `schwab_connections.access_token` / `refresh_token``TEXT`, encrypted at rest; `refresh_token_expires_at` reset on every token exchange
- `plaid_items.access_token``EncryptedText` (Fernet); `cursor` is VARCHAR(500), NULL = full history on next sync
- `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
### Migration Scripts
```
scripts/add_investment_account.py — adds investments.account_id column (run once)
scripts/add_security_columns.py — adds totp columns, audit_logs table, widens token columns to TEXT (run once)
scripts/add_plaid_tables.py — creates plaid_items, plaid_accounts, plaid_sync_previews tables (run once)
scripts/add_log_tables.py — creates audit_logs and app_logs tables (run once; safe to re-run)
```
---
@@ -256,8 +301,8 @@ pfm/ # /home/pfm/web on server
│ └── app.log
├── app/
│ ├── __init__.py # session idle timeout hook; Sentry init; limiter init
│ ├── config.py # SENTRY_DSN, SESSION_IDLE_MINUTES, RATELIMIT_STORAGE_URI added
│ ├── __init__.py # session idle timeout hook; Sentry init; limiter init; DBLogHandler registered
│ ├── config.py # SENTRY_DSN, SESSION_IDLE_MINUTES, RATELIMIT_STORAGE_URI, PLAID_* added
│ ├── extensions.py # + limiter (flask-limiter, storage via RATELIMIT_STORAGE_URI)
│ │
│ ├── models/
@@ -274,25 +319,28 @@ pfm/ # /home/pfm/web on server
│ │ ├── ai_insight.py
│ │ ├── fx_rate.py
│ │ ├── audit_log.py # AuditLog model (action, description, ip_address, timestamp)
│ │ ├── app_log.py # AppLog model (timestamp, level, module, message TEXT)
│ │ ├── teller_enrollment.py # access_token now EncryptedText (TEXT column)
│ │ ── schwab_connection.py # tokens now EncryptedText; + refresh_token_expires_at
│ │ ── schwab_connection.py # tokens now EncryptedText; + refresh_token_expires_at
│ │ └── plaid_item.py # PlaidItem, PlaidAccount, PlaidSyncPreview models
│ │
│ ├── routes/
│ │ ├── auth.py # + TOTP verify/setup/disable routes; rate limits; audit calls
│ │ ├── dashboard.py # + savings_rate; Schwab expiry warning
│ │ ├── accounts.py # teller_map + schwab_map; skip calc_balance for providers
│ │ ├── dashboard.py # + savings_rate; Schwab expiry warning; reconcile API; checking/savings/investment totals
│ │ ├── accounts.py # teller_map + schwab_map + plaid_map; skip calc_balance for providers
│ │ ├── categories.py
│ │ ├── transactions.py # + set-category AJAX; fixed income-form-submits-as-expense bug
│ │ ├── transactions.py # + set-category AJAX; bulk-action AJAX; quick date filter vars; fixed income-form-submits-as-expense bug
│ │ ├── budgets.py
│ │ ├── goals.py
│ │ ├── investments.py # + sync-schwab route; price-history API
│ │ ├── reports.py
│ │ ├── ai.py
│ │ ├── settings.py # + audit_log route; audit calls on password change
│ │ ├── settings.py # + audit_log route; audit_purge route; audit calls on password change
│ │ ├── teller.py # balance uses ledger/available correctly
│ │ ├── schwab.py # OAuth, mapping, sync, snapshot; audit calls; fallback type 'other'
│ │ ├── plaid.py # Link flow, exchange, map, sync preview/confirm, balance, liabilities, resync, disconnect
│ │ ├── bank_import.py
│ │ └── logs.py
│ │ └── logs.py # DB-backed API; purge endpoint; clear truncates DB + file
│ │
│ ├── services/
│ │ ├── account_service.py
@@ -308,28 +356,37 @@ pfm/ # /home/pfm/web on server
│ │ ├── report_service.py
│ │ ├── teller_service.py # auto_categorize; correct sign convention; live balance after sync
│ │ ├── schwab_service.py # + expanded ACCOUNT_TYPE_MAP; refresh_token_expires_at always reset
│ │ ├── plaid_service.py # Link token, exchange, accounts, balances, liabilities, cursor sync, parse, import
│ │ └── bank_import_service.py
│ │
│ ├── templates/
│ │ ├── base.html # mobile responsive tweaks
│ │ ├── base.html # mobile responsive tweaks; Teller/Schwab removed from sidebar
│ │ ├── auth/totp_setup.html # QR code + manual key entry for 2FA setup
│ │ ├── auth/totp_verify.html # 6-digit code entry on login
│ │ ├── dashboard/index.html # + savings_rate card; Schwab warning banner
│ │ ├── accounts/index.html # Teller/Schwab badges + action buttons
│ │ ├── transactions/index.html # inline category <select> + AJAX; pagination info
│ │ ├── dashboard/index.html # + savings_rate card; Schwab warning; Reconcile btn; Checking/Savings + Investments cards
│ │ ├── accounts/index.html # Teller/Schwab/Plaid badges + action buttons; Plaid CC billing card
│ │ ├── transactions/index.html # inline category <select>; bulk actions toolbar; quick date filters
│ │ ├── investments/index.html # per-account sections; Sync Schwab btn
│ │ ├── investments/detail.html # + price history chart (1W/1M/3M/6M/1Y)
│ │ ├── settings/audit.html # audit log viewer with event filter
│ │ ├── settings/index.html # + 2FA section; audit log nav card
│ │ ├── teller/index.html # connect/disconnect only
│ │ ├── schwab/ # index.html, map_accounts.html, preview.html
│ │ ├── settings/audit.html # audit log viewer with event filter + Purge dropdown; ← Settings back btn
│ │ ├── settings/index.html # + 2FA section; audit log nav card; Plaid Sync nav card
│ │ ├── settings/profile.html # + ← Settings back btn
│ │ ├── settings/password.html # + ← Settings back btn
│ │ ├── settings/recurring.html # + ← Settings back btn
│ │ ├── settings/recurring_form.html # + ← Recurring back btn
│ │ ├── settings/import.html # + ← Settings back btn
│ │ ├── teller/index.html # connect/disconnect only; + ← Settings back btn
│ │ ├── schwab/ # index.html (+ ← Settings back btn), map_accounts.html, preview.html
│ │ ├── plaid/ # index.html (+ ← Settings back btn), map_accounts.html, preview.html
│ │ ├── logs/index.html # DB-backed viewer; Purge dropdown; ← Settings back btn moved to topbar
│ │ └── ... (other templates unchanged)
│ │
│ └── utils/
│ ├── formatters.py
│ ├── decorators.py
│ ├── audit.py # audit() helper — writes AuditLog rows; swallows DB errors
── crypto.py # EncryptedText SQLAlchemy TypeDecorator (Fernet, key=SHA256(SECRET_KEY))
── crypto.py # EncryptedText SQLAlchemy TypeDecorator (Fernet, key=SHA256(SECRET_KEY))
│ └── db_log_handler.py # DBLogHandler — writes app.* log records to app_logs table; reentrancy guard; swallows errors
├── scripts/
│ ├── init_db.py
@@ -340,6 +397,8 @@ pfm/ # /home/pfm/web on server
│ ├── daily_ai_insight.py
│ ├── add_investment_account.py # adds investments.account_id column
│ ├── add_security_columns.py # adds TOTP cols, audit_logs table, widens token cols to TEXT
│ ├── add_plaid_tables.py # creates plaid_items, plaid_accounts, plaid_sync_previews
│ ├── add_log_tables.py # creates audit_logs + app_logs tables (safe to re-run)
│ └── sync_schwab.py # daily Schwab auto-sync (balance + positions + transactions)
└── tests/
@@ -447,31 +506,35 @@ sentry-sdk[flask]==2.7.0
| Unlinked (no provider) | `calc_balance()` from transactions | After every txn add/edit/delete; on accounts page load |
| Teller-linked | Teller API `available` (bank) or `ledger` (credit card) | After Teller sync; when Refresh button clicked |
| Schwab-linked | Schwab API `liquidationValue` | After Schwab sync; when Balance & Positions clicked |
| Plaid-linked | Plaid API `available` (bank) or `current` (credit card, stored negative) | After Plaid sync; when Refresh button clicked |
**Key rule**: accounts page load calls `calc_balance` ONLY for accounts NOT in `teller_map` or `schwab_map`. Dashboard does NOT call `calc_balance` (reads stored values).
**Key rule**: accounts page load calls `calc_balance` ONLY for accounts NOT in `teller_map`, `schwab_map`, or `plaid_map`. Dashboard does NOT call `calc_balance` (reads stored values).
---
## 11. Logging System
- Config key: `LOG_FILE_PATH` (default: `<project-root>/logs/app.log`)
- Handler: `RotatingFileHandler` — 10 MB per file, 5 backups
- Format: `YYYY-MM-DD HH:MM:SS|LEVEL|module.name|message`
- **File handler**: `RotatingFileHandler` — 10 MB per file, 5 backups; kept for download/external tools
- **DB handler**: `DBLogHandler` (`app/utils/db_log_handler.py`) — mirrors every `app.*` log record into `app_logs` table; has reentrancy guard (skips `sqlalchemy.*` / `werkzeug` to prevent recursion); swallows all errors so a DB issue never crashes the app
- Format: `YYYY-MM-DD HH:MM:SS|LEVEL|module.name|message` (file); fields stored separately in DB
- Namespace: `logging.getLogger('app')` at INFO; `propagate=False`
- Schwab snapshot sync logs: number of positions returned, per-position symbol/type/qty/mapped-type
- Viewer queries `app_logs` DB table (not file); file used only for download
- Purge via `POST /logs/purge` with `days=7|30|90`; audit log purge via `POST /settings/audit/purge`
---
## 12. UI/UX
- **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay
- **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay; Teller Sync and Schwab Sync **removed** — accessible via Settings only
- **Charts**: Chart.js 4.x (CDN)
- **Forms**: WTForms + Bootstrap 5.3
- **Icons**: Bootstrap Icons 1.11
- **Fonts**: DM Sans + DM Mono (Google Fonts CDN)
- **Color scheme**: `#0f172a` sidebar, `#f1f5f9` body, `#10b981` income, `#ef4444` expense, `#3b82f6` invest
- **Color scheme**: `#0f172a` sidebar, `#f1f5f9` body, `#10b981` income, `#ef4444` expense, `#3b82f6` invest, `#7c3aed` plaid/purple
- **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 %}`
---
@@ -483,7 +546,7 @@ sentry-sdk[flask]==2.7.0
- **Session idle timeout** — configurable via `SESSION_IDLE_MINUTES` (default 60); enforced in `before_request` hook
- **TOTP 2FA** — optional TOTP second factor (pyotp); setup via QR code; verify endpoint rate-limited `10/min; 30/hr`; 5 failed attempts clears pending session and forces re-login
- **Rate limiting** — flask-limiter on login (`10/min; 30/hr`), TOTP verify (`10/min; 30/hr`), TOTP setup (`10/min`); storage backend set via `RATELIMIT_STORAGE_URI` (use Redis in production to share limits across Gunicorn workers; defaults to `memory://` per-process if unset)
- **At-rest encryption** — Teller and Schwab OAuth tokens encrypted in DB via `EncryptedText` SQLAlchemy TypeDecorator (Fernet symmetric, key = SHA-256(SECRET_KEY)); columns are `TEXT` not `VARCHAR`
- **At-rest encryption** — Teller, Schwab, and Plaid OAuth tokens encrypted in DB via `EncryptedText` SQLAlchemy TypeDecorator (Fernet symmetric, key = SHA-256(SECRET_KEY)); columns are `TEXT` not `VARCHAR`
- **Audit log** — security events written to `audit_logs` table via `app/utils/audit.py`; events: `login_success`, `login_success_2fa`, `login_failed`, `login_failed_2fa`, `totp_enabled`, `totp_disabled`, `password_changed`, `schwab_connected`, `schwab_disconnected`
- CSRF protection on all forms (Flask-WTF); meta tag in base.html for AJAX
- SQLAlchemy ORM (no raw SQL)
@@ -532,6 +595,10 @@ TELLER_WEBHOOK_SECRET=your-webhook-secret
SCHWAB_CLIENT_ID=your-schwab-client-id
SCHWAB_CLIENT_SECRET=your-schwab-client-secret
SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback
# Plaid (sandbox / production — 'development' is retired by Plaid)
PLAID_CLIENT_ID=your-plaid-client-id
PLAID_SECRET=your-plaid-secret
PLAID_ENV=sandbox
# Security (optional)
SENTRY_DSN= # leave blank to disable Sentry
SESSION_IDLE_MINUTES=60 # session idle timeout in minutes
@@ -540,25 +607,27 @@ RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits a
---
## 16. Blueprints Registered (15 total)
## 16. Blueprints Registered (17 total)
| Blueprint | Prefix | Key routes |
|-----------|--------|------------|
| health | (none) | /health (public, no auth) |
| auth | /auth | login, logout, totp/verify, totp/setup, totp/disable |
| dashboard | / | index, api/fx-history, api/fx-refresh |
| dashboard | / | index, api/fx-history, api/fx-refresh, api/reconcile |
| accounts | /accounts | CRUD, adjust |
| categories | /categories | CRUD |
| transactions | /transactions | index, new, edit, delete, transfer, ocr, ocr-file, `<id>/set-category` |
| transactions | /transactions | index, new, edit, delete, transfer, ocr, ocr-file, `<id>/set-category`, bulk-action |
| budgets | /budgets | index, new, edit, delete, copy |
| goals | /goals | index, new, edit, delete, contribute, contributions |
| 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, recurring, import, recalc-balances, upload_receipt, delete_receipt, view_receipt |
| settings | /settings | index, profile, password, audit, audit/purge, recurring, import, recalc-balances, upload_receipt, delete_receipt, view_receipt |
| teller | /teller | callback, map, index, sync, sync/confirm, sync/all, balance, balance/all, resync, disconnect, webhook |
| schwab | /schwab | connect, callback, index, map, sync/`<id>`, sync/confirm, resync, snapshot/`<id>`, disconnect |
| plaid | /plaid | index, create-link-token, exchange-token, map/`<id>`, sync/`<id>`, sync/confirm, balance/`<pa_id>`, liabilities/`<id>`, resync/`<id>`, disconnect/`<id>`, webhook, update-webhook |
| bank_import | /bank-import | index, parse (AJAX), import (AJAX) |
| logs | /logs | index, api (AJAX), clear (AJAX), download |
| logs | /logs | index, api (AJAX), clear (AJAX), download, purge (AJAX) |
---
@@ -568,14 +637,18 @@ RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits a
- FX rate widget: `open.er-api.com` may return stale values; yfinance is the reliable primary
- WeasyPrint PDF: requires `libpango*` system libs on server
- Bank statement PDF import: scanned/image PDFs have no text layer; must use digital download
- Schwab: run `scripts/add_investment_account.py` then `scripts/add_security_columns.py` once after fresh deploy
- Schwab: run `scripts/add_investment_account.py` then `scripts/add_security_columns.py` then `scripts/add_log_tables.py` once after fresh deploy
- Schwab: after first connect, run "Balance & Positions" to populate investments; then re-sync if holdings were already added manually
- Schwab refresh token: Schwab tokens last ~7 days; `refresh_token_expires_at` is reset on every token exchange (including access-only refreshes); dashboard warns at ≤ 2 days
- Teller: `access_token` column is `TEXT` (widened from VARCHAR(128) to fit Fernet-encrypted values); run `scripts/add_security_columns.py` to apply
- Teller: development environment only; requires cert/key from Teller Dashboard
- Plaid: run `scripts/add_plaid_tables.py` once after fresh deploy; `development` environment retired — use `sandbox` or `production`
- Plaid: `resync` clears cursor so full history is re-fetched on next sync; duplicates are skipped automatically via `Plaid:<id>` in notes
- App logs: run `scripts/add_log_tables.py` to create `audit_logs` and `app_logs` tables; `DBLogHandler` is registered in `create_app()` after `db.init_app()`; fails silently if table doesn't exist yet
- Rate limiter: defaults to `memory://` per-process if `RATELIMIT_STORAGE_URI` is not set — effective limit is `stated_limit × num_workers`; set `RATELIMIT_STORAGE_URI=redis://localhost:6379` in production
- `EncryptedText` TypeDecorator: key = SHA-256(SECRET_KEY); changing SECRET_KEY invalidates all stored tokens (requires reconnect)
- `EncryptedText` TypeDecorator: key = SHA-256(SECRET_KEY); changing SECRET_KEY invalidates all stored tokens (requires reconnect for Teller, Schwab, and Plaid)
- MySQL does not support `NULLS LAST`; use `func.isnull(column)` for null-last ordering
- Reconcile button: matches categories by name ILIKE `%transfer%`; if no such categories exist, shows "No internal transfers found" rather than silently changing nothing
---
@@ -630,3 +703,12 @@ RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits a
- [x] **Schwab IRA account type** — IRA/ROTH_IRA/401K/BROKERAGE types added to ACCOUNT_TYPE_MAP
- [x] **Investment price history chart** — 1W/1M/3M/6M/1Y chart on investment detail page
- [x] **Schwab auto-sync on schedule**`scripts/sync_schwab.py` (cron at 7AM daily)
- [x] **Plaid bank sync** — full integration: Link widget, token exchange, account mapping, cursor-based sync, liabilities (CC due date/min payment), balance refresh, resync reset
- [x] **Bulk actions on transactions** — checkbox select-all, bulk delete, bulk set category via `POST /transactions/bulk-action`
- [x] **Quick date filters on transactions** — "This Month" / "Last Month" buttons with active highlight
- [x] **Back button on all Settings sub-pages**`← Settings` in topbar_actions on all pages reachable from Settings
- [x] **Teller/Schwab/Plaid removed from sidebar** — accessed via Settings only
- [x] **App logs to DB**`DBLogHandler` mirrors `app.*` logs to `app_logs` table; viewer queries DB; purge by 7/30/90 days
- [x] **Audit log purge**`POST /settings/audit/purge` with 7/30/90 day options
- [x] **Dashboard Reconcile button**`GET /api/reconcile`; excludes transfer-category transactions; toggles stat cards in-place
- [x] **Dashboard Checking & Savings + Investments cards** — net worth breakdown into liquid vs investment balances
+2
View File
@@ -99,6 +99,7 @@ def create_app(config_name=None):
)
logging.getLogger('app').info('Sentry initialised')
from app.routes.health import health_bp
from app.routes.auth import auth_bp
from app.routes.dashboard import dashboard_bp
from app.routes.accounts import accounts_bp
@@ -116,6 +117,7 @@ def create_app(config_name=None):
from app.routes.logs import logs_bp
from app.routes.bank_import import bank_import_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(accounts_bp)
+3
View File
@@ -35,6 +35,9 @@ class Config:
PLAID_SECRET = os.environ.get('PLAID_SECRET', '')
# Valid values: sandbox, production (Plaid retired the 'development' environment)
PLAID_ENV = os.environ.get('PLAID_ENV', 'sandbox')
# Full public URL Plaid will POST transaction webhooks to (e.g. https://pfm.ngodanguyen.tech/plaid/webhook)
# Leave blank to disable webhook registration during Link token creation
PLAID_WEBHOOK_URL = os.environ.get('PLAID_WEBHOOK_URL', '')
# Schwab Developer API (OAuth 2.0)
SCHWAB_CLIENT_ID = os.environ.get('SCHWAB_CLIENT_ID', '')
+87
View File
@@ -0,0 +1,87 @@
"""
Health check endpoint — no authentication required.
Used by uptime monitors (UptimeRobot, etc.) to verify the app and subsystems.
"""
import logging
import time
from datetime import datetime
from flask import Blueprint, jsonify
from sqlalchemy import func, text
from app.extensions import db
health_bp = Blueprint('health', __name__)
log = logging.getLogger('app.health')
@health_bp.route('/health')
def health_check():
db_result = _check_db()
cron_result = _check_crons()
status = 'ok'
if db_result['status'] != 'ok':
status = 'degraded'
if any(v.get('overdue') for v in cron_result.values() if isinstance(v, dict)):
status = 'degraded'
return jsonify({
'status': status,
'timestamp': datetime.utcnow().isoformat() + 'Z',
'db': db_result,
'crons': cron_result,
}), 200 if status == 'ok' else 503
def _check_db():
t0 = time.monotonic()
try:
db.session.execute(text('SELECT 1'))
ms = round((time.monotonic() - t0) * 1000, 1)
return {'status': 'ok', 'latency_ms': ms}
except Exception as e:
log.error('[health] DB check failed: %s', e)
return {'status': 'error', 'error': str(e)}
def _check_crons():
from app.models.fx_rate import FxRate
from app.models.ai_insight import AiInsight
from app.models.net_worth_snapshot import NetWorthSnapshot
from app.models.investment import Investment
return {
# expected daily — overdue after 48 h
'fetch_fx_rate': _stat(
db.session.query(func.max(FxRate.fetched_at)).scalar(),
max_hours=48,
),
# expected daily — overdue after 48 h
'daily_ai_insight': _stat(
db.session.query(func.max(AiInsight.created_at))
.filter(AiInsight.insight_type == 'daily_summary').scalar(),
max_hours=48,
),
# expected monthly — overdue after 35 days
'daily_snapshot': _stat(
db.session.query(func.max(NetWorthSnapshot.created_at)).scalar(),
max_hours=35 * 24,
),
# expected weekdays — overdue after 4 days
'fetch_prices': _stat(
db.session.query(func.max(Investment.last_price_update)).scalar(),
max_hours=4 * 24,
),
}
def _stat(last_run_dt, max_hours):
if last_run_dt is None:
return {'last_run': None, 'age_hours': None, 'overdue': False}
age = round((datetime.utcnow() - last_run_dt).total_seconds() / 3600, 1)
return {
'last_run': last_run_dt.isoformat() + 'Z',
'age_hours': age,
'overdue': age > max_hours,
}
+95 -1
View File
@@ -6,13 +6,14 @@ from flask import (Blueprint, render_template, redirect, url_for, flash,
request, jsonify, session, current_app)
from flask_login import login_required
from app.extensions import db
from app.extensions import db, csrf as _csrf
from app.models.account import Account
from app.models.plaid_item import PlaidItem, PlaidAccount, PlaidSyncPreview
from app.services.plaid_service import (
create_link_token, exchange_public_token,
get_accounts, get_balances,
refresh_liabilities, sync_preview, import_transactions,
verify_webhook_token, auto_sync_item, update_item_webhook,
)
plaid_bp = Blueprint('plaid', __name__, url_prefix='/plaid')
@@ -388,6 +389,99 @@ def full_resync(item_db_id):
return redirect(url_for('plaid.index'))
# ── Webhook Receiver ─────────────────────────────────────────────────────────
@plaid_bp.route('/webhook', methods=['POST'])
@_csrf.exempt
def webhook():
"""
Receive Plaid transaction webhooks.
Plaid signs every request with a JWT in the Plaid-Verification header (ES256).
On TRANSACTIONS events: auto-import new transactions without requiring user confirmation.
On ITEM errors: log for operator visibility.
"""
token = request.headers.get('Plaid-Verification', '')
if not token:
log.warning('[plaid] webhook received without Plaid-Verification header')
return jsonify({'error': 'Missing verification token'}), 400
try:
verify_webhook_token(token)
except Exception as e:
log.warning('[plaid] webhook JWT verification failed: %s', e)
return jsonify({'error': 'Verification failed'}), 401
payload = request.get_json(silent=True) or {}
wh_type = payload.get('webhook_type', '')
wh_code = payload.get('webhook_code', '')
item_id = payload.get('item_id', '')
log.info('[plaid] webhook %s/%s item_id=%s', wh_type, wh_code, item_id)
if wh_type == 'TRANSACTIONS' and wh_code in (
'SYNC_UPDATES_AVAILABLE', 'DEFAULT_UPDATE',
'HISTORICAL_UPDATE', 'INITIAL_UPDATE',
):
item = PlaidItem.query.filter_by(item_id=item_id, is_active=True).first()
if not item:
log.warning('[plaid] webhook: no active item for item_id=%s', item_id)
return jsonify({'ok': True}) # 200 so Plaid doesn't keep retrying
try:
imported, skipped, removed = auto_sync_item(item)
log.info('[plaid] webhook auto-sync done: +%d skipped=%d removed=%d',
imported, skipped, removed)
except Exception as e:
log.error('[plaid] webhook auto-sync failed for item_id=%s: %s',
item_id, e, exc_info=True)
# Still return 200 — error is logged; retrying won't help an app-level error
elif wh_type == 'ITEM':
error = payload.get('error') or {}
if wh_code == 'ERROR':
log.error('[plaid] ITEM/ERROR item_id=%s code=%s msg=%s',
item_id, error.get('error_code'), error.get('error_message'))
elif wh_code == 'PENDING_EXPIRATION':
log.warning('[plaid] ITEM/PENDING_EXPIRATION item_id=%s — re-auth required soon', item_id)
elif wh_code == 'USER_PERMISSION_REVOKED':
log.warning('[plaid] ITEM/USER_PERMISSION_REVOKED item_id=%s', item_id)
return jsonify({'ok': True})
# ── Update Webhook URL for Existing Items ─────────────────────────────────────
@plaid_bp.route('/update-webhook', methods=['POST'])
@login_required
def update_webhook():
"""
Tell Plaid to use the currently configured PLAID_WEBHOOK_URL for all active items.
Call this once after adding/changing PLAID_WEBHOOK_URL in .env for items that were
connected before the webhook was configured.
"""
webhook_url = current_app.config.get('PLAID_WEBHOOK_URL', '').strip()
if not webhook_url:
flash('PLAID_WEBHOOK_URL is not set in .env — nothing to update.', 'warning')
return redirect(url_for('plaid.index'))
items = PlaidItem.query.filter_by(is_active=True).all()
updated = 0
errors = 0
for item in items:
try:
update_item_webhook(item, webhook_url)
updated += 1
except Exception as e:
log.error('[plaid] update_item_webhook failed for item %s: %s', item.item_id, e)
errors += 1
if updated:
flash(f'Webhook URL updated for {updated} item(s).', 'success')
if errors:
flash(f'{errors} item(s) failed — check app logs.', 'warning')
return redirect(url_for('plaid.index'))
# ── Disconnect ────────────────────────────────────────────────────────────────
@plaid_bp.route('/disconnect/<int:item_db_id>', methods=['POST'])
+103 -2
View File
@@ -88,14 +88,19 @@ def _post(path, payload):
def create_link_token():
"""Create a Link token for the frontend Plaid Link widget."""
data = _post('/link/token/create', {
from flask import current_app
payload = {
'user': {'client_user_id': 'pfm-user'},
'client_name': 'Personal Finance Manager',
'products': ['transactions'],
'additional_consented_products': ['liabilities'],
'country_codes': ['US'],
'language': 'en',
})
}
webhook_url = current_app.config.get('PLAID_WEBHOOK_URL', '').strip()
if webhook_url:
payload['webhook'] = webhook_url
data = _post('/link/token/create', payload)
return data['link_token']
@@ -381,3 +386,99 @@ def import_transactions(parsed_txns, next_cursor, item):
calc_balance(acct_id)
return imported, skipped
# ── Webhook Verification ──────────────────────────────────────────────────────
def verify_webhook_token(token):
"""
Verify a Plaid webhook JWT from the Plaid-Verification header.
Plaid signs webhooks with a rotating EC key (ES256).
Raises ValueError / jwt.exceptions.* on invalid tokens.
"""
import json
import jwt as pyjwt
from datetime import timezone
# Decode header without verification to extract key ID
header = pyjwt.get_unverified_header(token)
kid = header.get('kid')
if not kid:
raise ValueError('Missing kid in Plaid webhook JWT header')
# Fetch the matching public key from Plaid
data = _post('/webhook_verification_key/get', {'key_id': kid})
jwk = data.get('key', {})
if not jwk:
raise ValueError('Plaid returned empty JWK')
pub_key = pyjwt.algorithms.ECAlgorithm.from_jwk(json.dumps(jwk))
# Verify signature and standard claims
decoded = pyjwt.decode(token, pub_key, algorithms=['ES256'])
# Reject tokens issued more than 5 minutes ago (replay protection)
import time
age = time.time() - decoded.get('iat', 0)
if age > 300:
raise ValueError(f'Plaid webhook JWT is too old ({age:.0f}s)')
return decoded
# ── Auto Sync (used by webhook handler — no preview step) ────────────────────
def auto_sync_item(item):
"""
Silently fetch and import new transactions for a Plaid item.
Called from the webhook handler — skips the preview/confirm UI flow.
Also handles removed transactions by deleting them from the DB.
Returns (imported, skipped, removed_count).
"""
from app.extensions import db
from app.models.transaction import Transaction
from app.models.plaid_item import PlaidAccount
added, modified, removed, next_cursor = sync_transactions(item)
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()
parsed = []
for txn in added + modified:
if txn.get('pending', False):
continue
p = parse_transaction(txn, plaid_account_map, cat_map)
if p['account_id'] is None:
continue
parsed.append(p)
imported, skipped = import_transactions(parsed, next_cursor, item)
# Remove transactions that Plaid says are gone (e.g. pending dropped)
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:
db.session.delete(txn)
removed_count += 1
if removed_count:
db.session.commit()
log.info('[plaid] auto_sync removed %d transaction(s) for item %s',
removed_count, item.item_id)
return imported, skipped, removed_count
def update_item_webhook(item, webhook_url):
"""Tell Plaid to send future webhooks for this item to a new URL."""
_post('/item/webhook/update', {
'access_token': item.access_token,
'webhook': webhook_url,
})
log.info('[plaid] webhook URL updated for item %s%s', item.item_id, webhook_url)
+33
View File
@@ -39,6 +39,39 @@
</div>
</div>
<!-- Webhook status card -->
<div class="pcard mb-4" style="border-left:3px solid {% if config.get('PLAID_WEBHOOK_URL') %}#7c3aed{% else %}#94a3b8{% endif %};">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px;">
<i class="bi bi-bell{% if config.get('PLAID_WEBHOOK_URL') %}-fill text-primary{% else %}{% endif %} me-1"></i>
Automatic Transaction Sync (Webhooks)
</div>
{% if config.get('PLAID_WEBHOOK_URL') %}
<div style="font-size:11px;color:var(--muted);">
Webhook URL: <code style="font-size:11px;">{{ config.get('PLAID_WEBHOOK_URL') }}</code>
— Plaid will push transaction updates automatically.
</div>
{% else %}
<div style="font-size:11px;color:var(--muted);">
Not configured. Set <code>PLAID_WEBHOOK_URL=https://pfm.ngodanguyen.tech/plaid/webhook</code>
in <code>.env</code> and restart. New connections will register it automatically.
For existing items, click "Apply to Existing" after setting the URL.
</div>
{% endif %}
</div>
{% if config.get('PLAID_WEBHOOK_URL') and items %}
<form method="POST" action="{{ url_for('plaid.update_webhook') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-primary" style="font-size:12px;"
title="Register the configured PLAID_WEBHOOK_URL with all connected Plaid items">
<i class="bi bi-arrow-repeat me-1"></i>Apply to Existing Items
</button>
</form>
{% endif %}
</div>
</div>
{% if items %}
{% for item in items %}
<div class="pcard mb-3">
+2
View File
@@ -22,3 +22,5 @@ pyotp==2.9.0
qrcode==7.4.2
# Monitoring
sentry-sdk[flask]==2.7.0
# Plaid webhook JWT verification (requires cryptography extra, already installed above)
PyJWT==2.9.0