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.**
**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
- Stale indicator if rate > 1 day old
- Period selector: This Month / Last Month / Custom date range
- Period selector: This Month / Last Month / Custom date range
- Credit card accounts display "owed" balance (positive Amount Owed) not raw negative
- 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
### 2.2 Transactions
- Income + Expense entry with Income/Expense tabs
- Income + Expense entry with Income/Expense tabs
- Transfer between accounts
- Transfer between accounts
- Filter: search, category, account, date range (safe int parsing — no crash on bad params)
- 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
- **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
### 2.12 USD → VND Exchange Rate
- **Reference widget only** — not used in transaction calculations
- **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
### 2.13 Teller Bank Sync
- Connects US bank accounts via Teller API (mTLS + HTTP Basic Auth)
- 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
- Shows: institution name, connected date, last synced, account list with linked PFM account names
- "Map Account" button for unmapped accounts
- "Map Account" button for unmapped accounts
- **Accounts page** — all Teller action buttons live here per account card (see §2.3)
- **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
- 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
- **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)
## 3. Database Schema (MySQL)
### All 19 Tables
### All 23 Tables
```
```
users — single user, hashed password, currency/timezone prefs, totp_secret, totp_enabled
users — single user, hashed password, currency/timezone prefs, totp_secret, totp_enabled
accounts — bank/wallet accounts (balance managed per provider rules)
accounts — bank/wallet accounts (balance managed per provider rules)
-`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.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
-`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
-`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
-`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
-`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
| Unlinked (no provider) | `calc_balance()` from transactions | After every txn add/edit/delete; on accounts page load |
| 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 |
| 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 |
| 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).
-**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`
- 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
## 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
- **CSS**: All inline in templates (no build step)
- **CSS**: All inline in templates (no build step)
- **CSRF meta tag**: `<meta name="csrf-token">` in `base.html` for JS fetch calls
- **CSRF meta tag**: `<meta name="csrf-token">` in `base.html` for JS fetch calls
- **Back buttons**: all Settings sub-pages have `← Settings` (or `← Recurring` for the recurring form) in `{% block topbar_actions %}`
---
---
@@ -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
- **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
- **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)
- **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`
- **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
- CSRF protection on all forms (Flask-WTF); meta tag in base.html for 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
- FX rate widget: `open.er-api.com` may return stale values; yfinance is the reliable primary
- WeasyPrint PDF: requires `libpango*` system libs on server
- WeasyPrint PDF: requires `libpango*` system libs on server
- Bank statement PDF import: scanned/image PDFs have no text layer; must use digital download
- 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: 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
- 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: `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
- 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
- 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
- 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]**Schwab IRA account type** — IRA/ROTH_IRA/401K/BROKERAGE types added to ACCOUNT_TYPE_MAP
- [x]**Investment price history chart** — 1W/1M/3M/6M/1Y chart on investment detail page
- [x]**Investment price history chart** — 1W/1M/3M/6M/1Y chart on investment detail page
- [x]**Schwab auto-sync on schedule** — `scripts/sync_schwab.py` (cron at 7AM daily)
- [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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.