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
↻ 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 <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
2.3 Accounts
Types: checking, savings, cash, credit_card, crypto, investment, other
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:
"Sync Schwab" button (topbar POST) — runs snapshot sync for all mapped Schwab accounts, redirects back to investments page
Per-account sections — when investments span multiple accounts (e.g. Individual + Roth IRA), holdings are grouped into one card per account, each showing account name, total value, and holdings table. Allocation sidebar also shows "By Account" breakdown
investments.account_id FK — each Schwab-synced holding is stamped with its source account; same ticker in different accounts (AAPL in Individual vs Roth IRA) stays as separate rows
Holdings table uses a Jinja2 {% macro %} (reused across single and multi-account views)
Price history chart on investment detail page — 1W / 1M / 3M / 6M / 1Y timeframe buttons; fetches from /investments/api/price-history/<ticker>
2.8 AI Financial Assistant
Chat UI with SSE streaming (Groq API, word-by-word response)
Context: last 90 days transactions + budget status + goals + investments
8 suggested question buttons
Daily auto-insight generated at midnight (stored in ai_insights table)
Manual "Generate Now" button
Chat history page
Model: llama-3.3-70b-versatile (default) or llama-3.1-8b-instant (fast)
Fallback messages for rate limit / invalid key / unavailable
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
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
Account hashes: Schwab requires hashValue (encrypted account number) in all API paths
On connect: calls GET /trader/v1/accounts/accountNumbers to get {accountNumber → hashValue} map
account_hash stored in DB is always the hashValue, never the raw account number
On reconnect: existing SchwabAccount records updated in-place (by hash, raw number, or masked display) to preserve pfm_account_id mapping
Account mapping: each Schwab account → PFM account (or create new); stored in schwab_accounts
Transaction sync: preview → confirm → import; uses same duplicate-skipping as other providers
After import: live balance fetched from Schwab API (not recalculated from transactions)
Balance + position snapshot (POST /schwab/snapshot/<id> or "Balance & Positions" button):
Fetches GET /trader/v1/accounts/{hash}?fields=positions
Updates linked PFM account balance from currentBalances.liquidationValue
Upserts Investment records for each long position, matched on (ticker, account_id)
Asset type mapping: EQUITY→stock, ETF→etf, MUTUAL_FUND→etf, FIXED_INCOME→bond, CASH_EQUIVALENT→cash, unknown→other
Position zero-quantity and empty-symbol positions skipped; null positions array guarded
Investments sync: topbar "Sync Schwab" button on investments page → POST /investments/sync-schwab → runs snapshot for all mapped accounts (ignores stale connection_id — always uses active connection)
Token auto-refresh: access token expires 30 min; refreshed automatically before API calls
Refresh token expiry: refresh_token_expires_at tracked in DB; reset on every successful token exchange; dashboard warns when ≤ 2 days remain
Account type map: CASH→checking, MARGIN/IRA/ROTH_IRA/ROLLOVER_IRA/TRADITIONAL_IRA/401K/ROTH_401K/BROKERAGE→investment; unknown types fall back to 'other'
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
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>, 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
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:<FITID> 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).
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
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)
index, api (AJAX), clear (AJAX), download, purge (AJAX)
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:<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 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
Mobile responsiveness pass — sidebar auto-collapses on mobile; tables scroll horizontally (base.html mobile CSS improved; further work needed)