# 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. Everything runs on Ubuntu server behind Nginx + Certbot SSL. **Status: All 7 phases complete + Receipt OCR post-MVP feature.** --- ## 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 ### 2.2 Transactions - Income + Expense entry with Income/Expense tabs - Transfer between accounts - Filter: search, category, account, date range - 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) - Export to CSV / Excel ### 2.3 Accounts - Types: checking, savings, cash, credit_card, crypto, investment, other - Balance auto-calculated from all transactions (not manually entered) - Color + icon picker - Soft delete ### 2.4 Categories - Expense + Income categories with color/icon - System categories (protected from delete) - Custom categories (user-created) - 14 expense + 7 income defaults seeded on init ### 2.5 Budget Planner - Monthly limits per expense category - Progress bars: green → amber (80%) → red (100%+) - Rollover unused budget to next month (toggle) - Copy previous month's budgets in one click - Unbudgeted spending shown with "Set Budget" prompt ### 2.6 Goals & Savings - Goals with target amount, target date, color, icon - Contribution tracking + history - Progress bar + projected completion date (based on avg monthly contrib) - Auto-complete on 100% - Emergency fund tracker (liquid assets vs 3-month / 6-month expense targets) ### 2.7 Investments - Asset types: stock, ETF, crypto, real_estate, bond, cash, other - Buy/sell/dividend/split transaction log - FIFO cost basis auto-recalculated from transaction log - yfinance price auto-fetch (daily 4PM weekdays via cron) - Manual price refresh button (portfolio page) - Live ticker check on add form - Doughnut allocation chart - P&L per holding + portfolio total ### 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 ### 2.9 Receipt OCR (Groq Vision) - Model: `meta-llama/llama-4-scout-17b-16e-instruct` - Drag-drop or click-to-upload on new expense form - Extracts: amount, date, merchant name, category suggestion, notes - Maps category suggestion → system category ID - Auto-fills form fields with green flash animation - Re-extract button on existing receipt (edit mode) - Auto-triggers OCR when image file selected in edit mode - Handles fenced markdown JSON output from LLM - Sanity check: rejects amounts < 1000 (catches garbage values) - Full error handling: 400/401/429/timeout/bad JSON ### 2.10 Reports & Export - Monthly / Quarterly / Yearly summary reports - Tax year summary (all income by source, all expenses by category) - Net worth history line chart (from monthly snapshots) - Category spending trends (top 6 categories, 6-month line chart) - "Snapshot Now" manual button - Export: CSV, Excel (color-coded, formatted), PDF (WeasyPrint) ### 2.11 Settings - Profile: name, email, timezone, currency, Groq model - 8 currency options (USD/VND/EUR/GBP/JPY/AUD/CAD/SGD) — auto-updates symbol - Password change (requires current password) - 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) ### 2.12 USD → VND Exchange Rate - **Reference widget only** — not used in transaction calculations - Primary source: yfinance `USDVND=X` (Yahoo Finance forex) - Fallback: `open.er-api.com` (free, no key) - Sanity check: rate must be > 1000 (rejects garbage values) - `force_refresh()` — always fetches fresh on cron, bypasses cache - DB caches one record per day (`fx_rates` table) - Dashboard: shows rate, date, source, stale warning, ↻ refresh button - 30-day history chart (click widget to expand) --- ## 3. Database Schema (MySQL) ### All 14 Tables ``` users — single user, hashed password, currency/timezone prefs accounts — bank/wallet accounts (balance auto-calc from txns) categories — expense/income categories with color/icon transactions — income/expense/transfer, receipt_id, recurring_rule_id receipts — receipt file metadata (filename, size, mime_type) recurring_rules — templates: frequency, next_run, start/end date budgets — monthly limits per category, rollover support goals — savings goals with target amount/date goal_contributions — individual deposits toward each goal investments — holdings: ticker, shares, avg_cost_basis, current_price investment_transactions — buy/sell/dividend/split log net_worth_snapshots — monthly snapshots: assets, liabilities, net_worth (JSON) ai_insights — stored AI responses: daily_summary / chat_response fx_rates — daily USD/VND rate cache (date UNIQUE, source) ``` ### Key Column Notes - `transactions.balance` — NOT stored; calculated on-demand via `account_service.calc_balance()` - `investments.shares` / `avg_cost_basis` — recalculated from `investment_transactions` (FIFO) - `goals.current_amount` — updated on each contribution add/delete - `net_worth_snapshots.account_balances` — JSON snapshot of each account balance at time of snapshot --- ## 4. Project File Structure (Actual) ``` pfm/ # /home/pfm/web on server ├── wsgi.py # Gunicorn entry — sys.path fix included ├── requirements.txt ├── .env # Not committed ├── .env.example ├── .gitignore │ ├── app/ │ ├── __init__.py # Flask app factory, all blueprints registered │ ├── config.py # Dev/Prod configs, SESSION_COOKIE_SECURE in prod │ ├── extensions.py # db, login_manager, migrate, csrf │ │ │ ├── models/ │ │ ├── __init__.py # Imports all models (required for Flask-Migrate) │ │ ├── user.py # UserMixin, set/check password, load_user hook │ │ ├── account.py # account_type enum, color, icon │ │ ├── category.py # Self-referential (subcategories), is_system flag │ │ ├── transaction.py # Dual FK to accounts (account_id + to_account_id) │ │ ├── receipt.py # filename, original_filename, mime_type │ │ ├── recurring_rule.py # frequency enum, next_run date │ │ ├── budget.py # UniqueConstraint(category_id, month) │ │ ├── goal.py # progress_percent property │ │ ├── investment.py # total_cost/current_value/unrealized_gain properties │ │ ├── net_worth_snapshot.py # account_balances JSON field │ │ ├── ai_insight.py # insight_type enum │ │ └── fx_rate.py # date UNIQUE index │ │ │ ├── routes/ │ │ ├── auth.py # /auth/login, /auth/logout │ │ ├── dashboard.py # /, /api/fx-history, /api/fx-refresh (POST) │ │ ├── accounts.py # /accounts/ │ │ ├── categories.py # /categories/ │ │ ├── transactions.py # /transactions/, /transactions/ocr (POST), │ │ │ # /transactions/ocr-file (POST) │ │ ├── budgets.py # /budgets/, /budgets/copy (POST) │ │ ├── goals.py # /goals/, contribute, contributions, delete_contribution │ │ ├── investments.py # /investments/, detail, add_transaction, │ │ │ # refresh-prices, /api/price/ │ │ ├── reports.py # /reports/monthly|quarterly|yearly|tax │ │ │ # /reports/export/csv|excel|pdf │ │ ├── ai.py # /ai/, /ai/stream (SSE), /ai/history, │ │ │ # /ai/generate-insight (POST) │ │ └── settings.py # /settings/, profile, password, recurring, │ │ # import, upload_receipt, delete_receipt, view_receipt │ │ │ ├── services/ │ │ ├── account_service.py # calc_balance(), recalc_all(), get_total_assets/liabilities() │ │ ├── ai_service.py # build_context(), stream_chat() SSE gen, generate_daily_insight() │ │ ├── budget_service.py # get_budget_summary(), apply_rollovers() │ │ ├── export_service.py # transactions_to_csv/excel(), report_to_pdf(), build_report_html() │ │ ├── fx_service.py # get_today_rate(), force_refresh(), _fetch_yfinance(), │ │ │ # _fetch_er_api(), get_rate_history() │ │ ├── goal_service.py # get_projected_completion(), get_emergency_fund_status() │ │ ├── import_service.py # parse_csv(), import_rows(), duplicate detection │ │ ├── investment_service.py # fetch_price(), update_prices(), get_portfolio_summary() │ │ ├── ocr_service.py # extract_from_file(), extract_from_bytes(), │ │ │ # Groq vision model, JSON parse + sanitise │ │ ├── recurring_service.py # process_due_rules(), next_occurrence(), get_upcoming() │ │ └── report_service.py # monthly/quarterly/yearly/tax reports, │ │ # net_worth_history(), category_trends(), take_net_worth_snapshot() │ │ │ ├── templates/ │ │ ├── base.html # Collapsible sidebar, topbar, flash messages, │ │ │ # @keyframes spin, Bootstrap 5 + Bootstrap Icons │ │ ├── auth/login.html # Dark themed, password toggle │ │ ├── dashboard/index.html # All widgets, FX refresh JS, SSE-compatible │ │ ├── accounts/ # index.html, form.html (color/icon picker) │ │ ├── categories/ # index.html, form.html │ │ ├── transactions/ # index.html (tabs+filter+pagination) │ │ │ # form.html (OCR panel + drag-drop + field flash) │ │ │ # transfer.html │ │ ├── budgets/ # index.html (progress bars), form.html │ │ ├── goals/ # index.html (emergency fund + cards), form.html, │ │ │ # contribute.html, contributions.html │ │ ├── investments/ # index.html (doughnut chart), detail.html, │ │ │ # form.html (ticker check), transaction_form.html │ │ ├── reports/ # index.html (4 chart types), tax.html │ │ ├── ai/ # index.html (SSE chat + suggestions), history.html │ │ └── settings/ # index.html, profile.html, password.html, │ │ # recurring.html, recurring_form.html, import.html │ │ │ ├── static/ │ │ ├── css/ # (empty — all CSS inline in templates) │ │ ├── js/ # (empty — all JS inline in templates) │ │ └── img/ │ │ │ └── utils/ │ ├── formatters.py # format_currency(), format_percent(), format_large_number() │ └── decorators.py # login_required_custom (unused — Flask-Login handles it) │ ├── migrations/ # Flask-Migrate / Alembic │ ├── scripts/ # All cron scripts — sys.path fix at top of each │ ├── init_db.py # Seeds 21 default categories, creates admin user interactively │ ├── process_recurring.py # Processes due recurring rules, creates transactions │ ├── fetch_fx_rate.py # force_refresh() — always fetches fresh USD/VND │ ├── fetch_prices.py # yfinance price update for all investment tickers │ ├── daily_snapshot.py # Saves net worth snapshot (1st of month) │ └── daily_ai_insight.py # Generates daily AI summary via Groq │ └── 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 yfinance==0.2.54 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 ``` --- ## 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 (low, for consistent output) - Post-processing: strips markdown fences, extracts JSON via regex fallback, normalises amount/date, maps category to system names - Endpoints: `POST /transactions/ocr` (upload bytes), `POST /transactions/ocr-file` (stored file) ### Free Tier Limits | Metric | Limit | |--------|-------| | Requests/day | 14,400 | | Tokens/minute | 500,000 | | Cost | Free | --- ## 7. USD → VND Exchange Rate > **Reference widget only.** All transactions use the single configured app currency. ### Fetch Priority 1. DB cache — same day record in `fx_rates` 2. **yfinance** `USDVND=X` forex ticker (primary — most reliable) 3. `open.er-api.com` free REST API (fallback) 4. Last known DB record (stale fallback, shows ⚠ indicator) ### Sanity Check Rate must be `> 1000` — rejects garbage values (e.g. 1.0, 0.0) that some APIs return. ### Dashboard Widget - Shows rate, date, source - ↻ button → `POST /api/fx-refresh` → updates rate in-place without page reload - Click widget → toggles 30-day Chart.js line chart - `force_refresh()` used by daily cron — always bypasses cache --- ## 8. 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) - **SSE**: used for AI chat stream + FX refresh --- ## 9. Authentication - Single-user, Flask-Login, session-based - Hashed password (Werkzeug `generate_password_hash`) - `SESSION_COOKIE_SECURE=True` in production - `SESSION_COOKIE_HTTPONLY=True`, `SESSION_COOKIE_SAMESITE='Lax'` - CSRF protection on all forms (Flask-WTF) --- ## 10. Scheduled Jobs | Job | Schedule | Script | Notes | |-----|----------|--------|-------| | Process recurring transactions | Daily 6AM | `process_recurring.py` | Creates missed occurrences | | 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 | --- ## 11. 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 ``` --- ## 12. Blueprints Registered (11 total) | Blueprint | Prefix | Key routes | |-----------|--------|------------| | auth | /auth | login, logout | | dashboard | / | index, api/fx-history, api/fx-refresh | | accounts | /accounts | CRUD | | categories | /categories | CRUD | | transactions | /transactions | index, new, edit, delete, transfer, ocr, ocr-file | | 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, api/price | | 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, upload_receipt, delete_receipt, view_receipt | --- ## 13. Security Notes - All routes `@login_required` - CSRF on all POST forms - SQLAlchemy ORM (no raw SQL) - Receipt file path: `os.path.basename()` prevents path traversal - HTTPS via Let's Encrypt (Certbot) — `pfm.ngodanguyen.tech` - Groq receives anonymised transaction summaries (no account/personal names) - `GROQ_API_KEY` in `.env` (chmod 600), never in frontend --- ## 14. Known Issues / Notes - `wsgi.py` has `sys.path.insert(0, ...)` fix — required because app deploys at `/home/pfm/web/` which would otherwise be treated as a Python package - FX rate widget: `open.er-api.com` may return stale values; yfinance is the reliable primary - WeasyPrint PDF: requires `libpango*` system libs (included in deploy.md apt install) - Import preview uses Flask session to pass rows to confirm step — requires `SECRET_KEY` to be set --- ## 15. Post-MVP Roadmap - [ ] iOS companion app - [ ] Bank statement PDF auto-import (parse PDF → extract transactions) - [ ] Budget alerts + Twilio SMS/email notifications - [ ] Shared household mode (2 users, row-level isolation) - [ ] Mobile responsiveness pass