# 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) and Schwab Developer API (OAuth 2.0). 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) - 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 ### 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) - 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 ` + AJAX │ │ ├── investments/index.html # per-account sections; holdings_table macro; Sync Schwab btn │ │ ├── teller/index.html # connect/disconnect only (sync buttons removed) │ │ ├── schwab/ # index.html, map_accounts.html, preview.html (NEW) │ │ └── ... (other templates unchanged) │ │ │ └── utils/ │ ├── formatters.py │ └── decorators.py │ ├── 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 # NEW — adds investments.account_id column │ └── 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 ``` --- ## 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 | **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). --- ## 11. Logging System - Config key: `LOG_FILE_PATH` (default: `/logs/app.log`) - Handler: `RotatingFileHandler` — 10 MB per file, 5 backups - Format: `YYYY-MM-DD HH:MM:SS|LEVEL|module.name|message` - Namespace: `logging.getLogger('app')` at INFO; `propagate=False` - Schwab snapshot sync logs: number of positions returned, per-position symbol/type/qty/mapped-type --- ## 12. UI/UX - **Sidebar**: collapsible (desktop state saved in localStorage), mobile overlay - **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 - **CSS**: All inline in templates (no build step) - **CSRF meta tag**: `` in `base.html` for JS fetch calls --- ## 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'` - 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) --- ## 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 | | 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 ``` --- ## 16. Blueprints Registered (15 total) | Blueprint | Prefix | Key routes | |-----------|--------|------------| | auth | /auth | login, logout | | dashboard | / | index, api/fx-history, api/fx-refresh | | accounts | /accounts | CRUD, adjust | | categories | /categories | CRUD | | transactions | /transactions | index, new, edit, delete, transfer, ocr, ocr-file, `/set-category` | | 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, 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 | | bank_import | /bank-import | index, parse (AJAX), import (AJAX) | | logs | /logs | index, api (AJAX), clear (AJAX), download | --- ## 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: `investments.account_id` column requires migration — run `scripts/add_investment_account.py` once after deploy - Schwab: after first connect, run "Balance & Positions" to populate investments; then re-sync if holdings were already added manually (they will be updated to link to the account) - Teller: development environment only; requires cert/key from Teller Dashboard - MySQL does not support `NULLS LAST`; use `func.isnull(column)` for null-last ordering --- ## 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 | --- ## 19. To-Do / Roadmap ### High priority - [ ] **Mobile responsiveness pass** — sidebar auto-collapses on mobile; tables scroll horizontally - [ ] **Budget alerts** — email/Twilio SMS when category spending hits 80% / 100% ### Medium priority - [ ] **Pagination info** — show "Page X of Y" on AI history and other paginated pages - [ ] **PDF export memory** — stream CSV/Excel exports for users with large transaction history - [ ] **Receipt MIME validation** — validate file magic bytes server-side, not just extension - [ ] **OCR ownership check** — verify re-extracted filename belongs to current user's transaction - [ ] **Bank import progress** — show per-row import progress for large statement files - [ ] **Schwab IRA account type** — map Schwab IRA account type to `investment` in ACCOUNT_TYPE_MAP ### 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 - [ ] Investment price history chart per holding - [ ] Dark mode toggle - [ ] Schwab auto-sync on schedule (currently manual only)