# Personal Finance Management System (PFM) > Stack: Python Flask · MySQL · Ubuntu Server · Nginx · Gunicorn · Groq API (free AI) > App URL: https://pfm.ngodanguyen.tech > Code: /home/pfm/web · User: pfm · Gitea: gitea.ngodanguyen.tech --- ## 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), 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.** --- ## 2. Core Features (Implemented) ### 2.1 Dashboard - Net worth snapshot (assets − liabilities) - Monthly cash flow bar chart (6 months) - Budget utilization per category - Recent transactions feed (last 8) - AI daily insight card (Groq-generated, stored in DB) - **Savings Rate** stat card — net cash flow ÷ income for the selected period; green ≥ 20%, blue > 0%, red negative - **Schwab expiry warning** — banner shown when Schwab refresh token expires within 2 days - USD → VND exchange rate widget — reference only, independent of app currency - Click to expand 30-day history chart - ↻ refresh button (force-fetches fresh rate without page reload) - Source label shown (yfinance / exchangerate-api) - 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 `; 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 + 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 │ │ ├── utilities/ # index.html, providers.html, provider_form.html, detail.html, │ │ │ # bills.html, bill_form.html, pay.html, link.html │ │ └── ... (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)) │ └── db_log_handler.py # DBLogHandler — writes app.* log records to app_logs table; reentrancy guard; swallows errors │ ├── scripts/ │ ├── init_db.py │ ├── process_recurring.py │ ├── fetch_fx_rate.py │ ├── fetch_prices.py │ ├── daily_snapshot.py │ ├── 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) │ ├── add_utility_tables.py # creates utility_providers + utility_bills (safe to re-run) │ └── sync_schwab.py # daily Schwab auto-sync (balance + positions + transactions) │ └── tests/ ``` --- ## 5. Python Dependencies (`requirements.txt`) ``` flask==3.1.0 flask-sqlalchemy==3.1.1 flask-login==0.6.3 flask-migrate==4.1.0 flask-wtf==1.2.2 pymysql==1.1.1 python-dotenv==1.0.1 gunicorn==23.0.0 groq==0.13.1 weasyprint==63.1 openpyxl==3.1.5 Pillow==11.1.0 apscheduler==3.10.4 requests==2.32.3 cryptography==44.0.2 python-dateutil==2.9.0 pdfplumber==0.11.4 # Security flask-limiter==3.5.0 pyotp==2.9.0 qrcode==7.4.2 # Monitoring sentry-sdk[flask]==2.7.0 ``` --- ## 6. AI Integration — Groq API ### Chat + Daily Insights - Model: `llama-3.3-70b-versatile` (default) / `llama-3.1-8b-instant` (fast) - Context: last 90 days transactions, budget status, goals, investments (anonymised) - SSE streaming: `stream_chat()` yields `data: \n\n` - Daily insight: non-streaming, stored in `ai_insights` table, max 400 tokens ### Receipt OCR (Vision) - Model: `meta-llama/llama-4-scout-17b-16e-instruct` - Input: base64-encoded image (JPEG/PNG/GIF/WEBP) - Prompt: structured JSON extraction (amount, date, merchant, category, notes) - Temperature: 0.1 ### Bank Statement PDF Parsing - Text extracted by `pdfplumber` then sent to `llama-3.3-70b-versatile` - Max 30 K chars sent per request - Scanned PDFs rejected with clear error message ### Free Tier Limits | Metric | Limit | |--------|-------| | Requests/day | 14,400 | | Tokens/minute | 500,000 | | Cost | Free | --- ## 7. Teller Bank Sync - mTLS: client cert + key from `TELLER_CERT_PATH` / `TELLER_KEY_PATH` - HTTP Basic Auth: `access_token` as username, empty password - Endpoints: `GET /accounts`, `GET /accounts/:id/balances`, `GET /accounts/:id/transactions` - All API errors logged with status code + full response body - Webhook: HMAC-SHA256 `Teller-Signature` header; 5-minute replay window - **Balance convention**: credit cards use `ledger` (amount owed, stored as negative); bank accounts use `available` - **After transaction sync**: live balance re-fetched from Teller API instead of computing from transactions --- ## 8. Schwab Developer API - OAuth 2.0: `SCHWAB_AUTH_URL` + `SCHWAB_TOKEN_URL` - State parameter sent in auth URL (CSRF protection) - Account identification: `hashValue` from `/trader/v1/accounts/accountNumbers` (NOT raw account number) - Token refresh: access tokens expire 30 min; auto-refreshed via `_ensure_fresh(connection)` - Endpoints: - `GET /trader/v1/accounts/accountNumbers` → `{accountNumber: hashValue}` map - `GET /trader/v1/accounts?fields=positions` → accounts list with balances + positions - `GET /trader/v1/accounts/{hash}?fields=positions` → single account - `GET /trader/v1/accounts/{hash}/transactions?startDate&endDate` → transactions --- ## 9. Bank Statement Import - Route: `/bank-import/` (blueprint `bank_import_bp`) - Parse: `POST /bank-import/parse` (AJAX, multipart with `X-CSRFToken` header) - Import: `POST /bank-import/import` (AJAX, JSON with `X-CSRFToken` header) - Duplicate detection: OFX `import:` in notes; CSV/PDF: date+amount+type+description scoped to account_id --- ## 10. Account Balance Rules | Account type | Balance source | When updated | |---|---|---| | 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`, `schwab_map`, or `plaid_map`. Dashboard does NOT call `calc_balance` (reads stored values). --- ## 11. Logging System - Config key: `LOG_FILE_PATH` (default: `/logs/app.log`) - **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` - 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; Teller Sync and Schwab Sync **removed** — accessible via Settings only; Utilities sits under Money with an amber badge counting bills due within 7 days (`utility_due_count`, set in the `inject_globals` context processor) - **Mobile responsive**: sidebar goes off-canvas with a dimmed overlay under 769px (topbar toggle button opens/closes it); topbar and main content collapse to full width; tables scroll horizontally via `.table-wrap` / `.pcard.p-0` wrapper classes + `.pfm-table` min-widths, with `.d-mob-none` hiding low-priority columns first; under 576px, button labels hide to icon-only (`.btn-label`) and chart/chat heights are capped (base.html) - **Dark mode**: toggle button in topbar (moon/sun icon); persisted via `localStorage['pfm_dark']`; applied pre-paint via a `data-pfm-dark` attribute to avoid flash-of-light-mode; CSS variable overrides plus targeted `[style*="..."]` overrides for hardcoded inline colors in templates (base.html) - **Charts**: Chart.js 4.x (CDN) - **Forms**: WTForms + Bootstrap 5.3 - **Icons**: Bootstrap Icons 1.11 - **Fonts**: DM Sans + DM Mono (Google Fonts CDN) - **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**: `` 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) --- ## 13. Authentication & Security - Single-user, Flask-Login, session-based - Hashed password (Werkzeug `generate_password_hash`) - `SESSION_COOKIE_SECURE=True`, `SESSION_COOKIE_HTTPONLY=True`, `SESSION_COOKIE_SAMESITE='Lax'` - **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, 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) - Schwab OAuth state parameter validated on callback (CSRF protection) - `next` redirect params validated to start with `/` (no open redirect) - **Sentry** (optional) — error monitoring; enable via `SENTRY_DSN` env var; `send_default_pii=False` --- ## 14. Scheduled Jobs | Job | Schedule | Script | Notes | |-----|----------|--------|-------| | Process recurring transactions | Daily 6AM | `process_recurring.py` | 90-day catchup cap | | Fetch USD/VND rate | Daily 8AM | `fetch_fx_rate.py` | force_refresh(), yfinance primary | | Fetch investment prices | Mon-Fri 4PM | `fetch_prices.py` | yfinance, all tickers | | Net worth snapshot | 1st of month 00:05 | `daily_snapshot.py` | Saves to net_worth_snapshots | | AI daily insight | Daily 00:01 | `daily_ai_insight.py` | Skips if already done today | | Schwab auto-sync | Daily 7AM | `sync_schwab.py` | Balance + positions + transactions; warns if refresh token expires soon | | DB backup | Daily 2AM | pfm-backup (systemd) | mysqldump → gzip, keep 30 days | --- ## 15. Environment Variables (`.env`) ``` SECRET_KEY=your-secret-key DATABASE_URL=mysql+pymysql://pfm_user:password@localhost/pfm_db GROQ_API_KEY=your-groq-api-key-here GROQ_MODEL=llama-3.3-70b-versatile UPLOAD_FOLDER=/home/pfm/web/uploads MAX_CONTENT_LENGTH=10485760 FLASK_ENV=production FLASK_APP=wsgi:app APP_CURRENCY=USD APP_CURRENCY_SYMBOL=$ APP_TIMEZONE=Asia/Ho_Chi_Minh LOG_FILE_PATH=/home/pfm/web/logs/app.log # Teller TELLER_APP_ID=your-teller-app-id TELLER_ENV=development TELLER_CERT_PATH=/home/pfm/teller/certificate.pem TELLER_KEY_PATH=/home/pfm/teller/private_key.pem TELLER_WEBHOOK_SECRET=your-webhook-secret # Schwab 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 RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits across Gunicorn workers # Budget alert emails (optional — leave blank to disable) SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= ALERT_EMAIL= # recipient address for budget alert emails APP_URL=https://pfm.ngodanguyen.tech # used to build links in alert emails ``` --- ## 16. Blueprints Registered (18 total) | Blueprint | Prefix | Key routes | |-----------|--------|------------| | health | (none) | /health (public, no auth) | | auth | /auth | login, logout, totp/verify, totp/setup, totp/disable | | dashboard | / | index, api/fx-history, api/fx-refresh, api/reconcile, api/health-score | | accounts | /accounts | CRUD, adjust | | categories | /categories | CRUD | | transactions | /transactions | index, new, edit, delete, transfer, ocr, ocr-file, `/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, 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/``, sync/confirm, resync, snapshot/``, disconnect | | plaid | /plaid | index, create-link-token, exchange-token, map/``, sync/``, sync/confirm, balance/``, liabilities/``, resync/``, disconnect/``, webhook, update-webhook | | bank_import | /bank-import | index, parse (AJAX), import (AJAX) | | logs | /logs | index, api (AJAX), clear (AJAX), download, purge (AJAX) | | utilities | /utilities | index, providers, providers/new, providers/``, providers/``/edit, providers/``/toggle, providers/``/delete, bills, bills/new, bills/``/edit, bills/``/delete, bills/``/pay, bills/``/link, bills/``/unpay, api/usage/`` | --- ## 17. Known Issues / Notes - `wsgi.py` has `sys.path.insert(0, ...)` — required for Gunicorn at `/home/pfm/web/` - 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` 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:` 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 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 --- ## 18. Security Fixes Applied (session log) | Date | Fix | File | |------|-----|------| | 2026-06 | Path traversal in `view_receipt` — added `os.path.basename()` | settings.py | | 2026-06 | `int()` crash on bad filter params in transactions index | transactions.py | | 2026-06 | Teller account mapping validates ID exists in DB | teller.py | | 2026-06 | Recurring catchup capped at 90 days (prevents runaway loops) | recurring_service.py | | 2026-06 | CSV/bank import duplicate check scoped by `account_id` | import_service.py | | 2026-06 | CSRF token added to bank import AJAX parse request | bank_import/index.html | | 2026-06 | Receipt sub-forms moved outside `#txnForm` (nested-form bug) | transactions/form.html | | 2026-06 | Drop zone file input moved outside overlay (blocked account select) | bank_import/index.html | | 2026-06 | Teller balance refresh uses `ledger` for credit cards (not `available`) | teller.py | | 2026-06 | Schwab OAuth `state` param added to auth URL (state mismatch fix) | schwab_service.py | | 2026-06 | Schwab uses `hashValue` (not raw account number) in API paths | schwab.py, schwab_service.py | | 2026-06 | `next` redirect params validated to start with `/` (no open redirect) | teller.py, schwab.py | | 2026-06 | Teller income/expense type corrected (positive = income) | teller_service.py | | 2026-06 | `EncryptedText.process_bind_param` removed silent plaintext fallback — raises on encrypt failure | crypto.py | | 2026-06 | Rate limiter storage moved to `RATELIMIT_STORAGE_URI` config (was hardcoded `memory://` per-worker) | extensions.py, config.py | | 2026-06 | TOTP verify: added hourly rate limit (`30/hr`) + per-session attempt counter (locks out after 5 failures) | auth.py | | 2026-06 | TOTP setup endpoint: added `@limiter.limit('10 per minute')` | auth.py | | 2026-06 | `refresh_token_expires_at` now reset on every token exchange, not only when Schwab rotates the token | schwab_service.py | | 2026-06 | Schwab unknown account type fallback changed back to `'other'` (was incorrectly changed to `'investment'`) | schwab.py | | 2026-06 | `teller_enrollments.access_token` widened VARCHAR(128) → TEXT to fit Fernet-encrypted values | add_security_columns.py | | 2026-06 | New income transaction submitted as expense — `form.transaction_type.data` not set on GET | transactions.py | --- ## 19. To-Do / Roadmap ### High priority *(none currently open)* ### Medium priority *(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 - [ ] Minor: bank-import column-mapping step (`.map-row` grid, fixed `160px 1fr`) feels cramped under ~360px viewport width — cosmetic only, not broken ### Completed (removed from backlog) - [x] **Mobile responsiveness pass** — sidebar off-canvas + overlay under 769px, topbar collapse, table horizontal scroll via `.table-wrap`/`.pcard.p-0` wrappers, `.d-mob-none` column hiding, icon-only buttons + capped chart/chat heights under 576px (base.html) - [x] **Dark mode toggle** — full implementation in base.html: toggle button, localStorage persistence, dark CSS variables, override rules for hardcoded inline colors - [x] **Budget alerts** — flash + email (SMTP) at 80%/100% of category budget, dedup flags, "Send test email" button ([2.18](#218-budget-alerts-email)) - [x] **Financial Health Score** — 0–100 score/grade from savings rate, budget adherence, goal progress, emergency fund ([2.17](#217-financial-health-score)) - [x] **Receipt MIME validation** — magic-byte sniffing in `settings.py`, rejects mismatched/renamed files - [x] **OCR ownership check** — re-extract requires filename to exist in `receipts` table - [x] **PDF export memory** — CSV/Excel exports now stream via generator + `yield_per(500)` - [x] **Bank import progress** — chunked import with live per-row progress bar - [x] **Pagination info** — "Page X of Y" added to transactions, AI history, accounts/payments, audit log - [x] **Schwab IRA account type** — IRA/ROTH_IRA/401K/BROKERAGE types added to ACCOUNT_TYPE_MAP - [x] **Investment price history chart** — 1W/1M/3M/6M/1Y chart on investment detail page - [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