05/31 Phase 1: initial codes

This commit is contained in:
2026-05-31 10:03:28 -04:00
parent b0a5ce5399
commit b0c0bd363b
35 changed files with 2871 additions and 164 deletions
+10
View File
@@ -0,0 +1,10 @@
SECRET_KEY=change-this-to-a-long-random-string
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/app/uploads
MAX_CONTENT_LENGTH=10485760
FLASK_ENV=production
APP_CURRENCY=USD
APP_CURRENCY_SYMBOL=$
APP_TIMEZONE=Asia/Ho_Chi_Minh
+30 -164
View File
@@ -1,176 +1,42 @@
# ---> Python # Environment
# Byte-compiled / optimized / DLL files .env
# Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *.pyo
*.pyd
# C extensions
*.so
# Distribution / packaging
.Python .Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg dist/
*.egg build/
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/ venv/
ENV/ .venv/
env.bak/
venv.bak/
# Spyder project settings # Flask / Migrations (keep structure, ignore compiled)
.spyderproject instance/
.spyproject
# Rope project settings # Uploads
.ropeproject uploads/
app/static/uploads/
# mkdocs documentation # Backups
/site backups/
# mypy # IDE
.mypy_cache/ .vscode/
.dmypy.json .idea/
dmypy.json *.swp
*.swo
# Pyre type checker # OS
.pyre/ .DS_Store
Thumbs.db
# pytype static type analyzer # Logs
.pytype/ *.log
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Tests
.pytest_cache/
htmlcov/
.coverage
+485
View File
@@ -0,0 +1,485 @@
# Personal Finance Management System (PFMS)
> Stack: Python Flask · MySQL · Ubuntu Server · Nginx · Gunicorn · Groq API (free AI)
---
## 1. Project Overview
Self-hosted personal finance web app. Tracks income, expenses, investments. AI assistant powered by **Groq API** (free tier, extremely fast inference, no local hardware needed). Everything runs on your Ubuntu server behind Nginx.
---
## 2. Core Features
### 2.1 Dashboard
- Net worth snapshot (assets liabilities)
- Monthly cash flow chart (income vs expenses)
- Budget utilization gauges per category
- Recent transactions feed
- AI insight card (auto-generated daily summary)
- Investment portfolio mini-widget
- **USD → VND exchange rate widget** (daily rate, fetched once/day, cached in DB — reference only, independent of app currency)
### 2.2 Income Management
- Log income entries (salary, freelance, passive, other)
- Recurring income templates (auto-create entries on schedule)
- Income source breakdown (chart by source)
- Month-over-month comparison
- Export to CSV/Excel
### 2.3 Expense Management
- Manual expense entry
- Category tagging (custom + predefined: Food, Rent, Utilities, Transport, Health, Entertainment, etc.)
- Subcategory support
- Receipt photo upload (stored locally)
- Recurring expense detection
- Budget limits per category with alert thresholds
- Expense search + filter (date range, category, amount range, keyword)
- Export to CSV/Excel
### 2.4 Investment Portfolio
- Asset types: Stocks, ETF, Crypto, Real Estate, Bonds, Cash, Other
- Holdings tracker (ticker, shares/units, buy price, current price)
- Manual price update OR auto-fetch via free API (Yahoo Finance via `yfinance`)
- P&L per holding (unrealized gain/loss)
- Portfolio allocation pie chart
- Transaction log (buy/sell history per asset)
- Cost basis tracking (FIFO)
### 2.5 Budget Planner
- Monthly budget templates
- Set budget limits per category
- Real-time spending vs budget comparison
- Rollover unused budget (optional toggle)
- Budget history archive
### 2.6 Goals & Savings
- Create savings goals (name, target amount, target date, linked account)
- Track contributions toward each goal
- Progress bar + projected completion date
- Emergency fund tracker (X months of expenses)
### 2.7 Reports & Analytics
- Monthly/quarterly/yearly summary reports
- Category spending trends (line chart over time)
- Income growth chart
- Net worth over time (historical snapshots, monthly auto-saved)
- Tax year summary (income + deductible expenses)
- Printable PDF report (via WeasyPrint)
### 2.8 Accounts & Wallets
- Multiple accounts (bank checking, savings, cash, credit card, crypto wallet)
- Account balances tracked manually
- Transfer between accounts (internal transaction)
- Credit card balance + due date tracking
### 2.9 AI Financial Assistant (Groq API — Free Tier)
- Chat interface (ask questions about your finances)
- Context: last 90 days of transactions injected into prompt
- Example queries:
- "Where did I overspend this month?"
- "Am I on track for my vacation goal?"
- "Summarize my Q1 spending"
- "What categories can I cut to save $500/month?"
- Auto-insight: daily AI summary generated at midnight via cron
- Model: `llama-3.3-70b-versatile` or `llama-3.1-8b-instant` via Groq (configurable in `.env`)
- Streaming response (SSE) for real-time chat feel
### 2.10 Notifications & Alerts
- Browser notifications (via Web Push or in-app toast)
- Budget threshold alerts (e.g., 80% of category budget used)
- Bill/recurring expense due reminders
- Goal milestone celebrations
### 2.11 Settings & Config
- Profile (name, timezone)
- **App currency** — single configurable currency (e.g. USD, VND, EUR — set once, used everywhere for all transactions/display)
- USD→VND rate source preference (ExchangeRate-API free or VCB scrape fallback)
- Category management (add/edit/delete custom categories)
- Data backup (export full MySQL dump)
- Data import (CSV import for bulk transactions)
- Groq model selector (choose speed vs quality)
---
## 3. Database Schema (MySQL)
### Tables
```
users — single user (self-hosted, no multi-tenant)
accounts — bank/wallet accounts
categories — expense/income categories
transactions — all money movements (income/expense/transfer)
investments — holdings/portfolio positions
investment_transactions — buy/sell log
budgets — monthly budget limits per category
goals — savings goals
goal_contributions — deposits toward each goal
net_worth_snapshots — monthly net worth history
ai_insights — stored daily AI summaries
recurring_rules — templates for recurring income/expenses
receipts — receipt image metadata
fx_rates — daily USD/VND rate cache (date, rate, source)
```
### Key Table: `transactions`
```sql
id, account_id, category_id, type (income/expense/transfer),
amount, currency, description, date, notes,
is_recurring, recurring_rule_id, receipt_id,
created_at, updated_at
```
### Key Table: `investments`
```sql
id, asset_name, ticker, asset_type (stock/etf/crypto/real_estate/bond/cash/other),
shares, avg_cost_basis, current_price, last_price_update,
currency, notes, created_at
```
### Key Table: `budgets`
```sql
id, category_id, month (YYYY-MM), limit_amount,
rollover_enabled, rollover_amount, created_at
```
---
## 4. Project File Structure
```
pfm/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── config.py # Config classes (dev/prod)
│ ├── extensions.py # db, login_manager, etc.
│ │
│ ├── models/
│ │ ├── user.py
│ │ ├── account.py
│ │ ├── transaction.py
│ │ ├── category.py
│ │ ├── investment.py
│ │ ├── budget.py
│ │ ├── goal.py
│ │ ├── ai_insight.py
│ │ ├── fx_rate.py
│ │ └── recurring_rule.py
│ │
│ ├── routes/
│ │ ├── auth.py # Login/logout (single user)
│ │ ├── dashboard.py
│ │ ├── transactions.py
│ │ ├── income.py
│ │ ├── expenses.py
│ │ ├── investments.py
│ │ ├── budgets.py
│ │ ├── goals.py
│ │ ├── accounts.py
│ │ ├── reports.py
│ │ ├── ai.py # AI chat + insights (SSE)
│ │ ├── settings.py
│ │ └── api.py # Internal JSON API endpoints
│ │
│ ├── services/
│ │ ├── ai_service.py # Groq API integration + context builder
│ │ ├── budget_service.py # Budget calc + alert logic
│ │ ├── investment_service.py # yfinance price fetcher
│ │ ├── fx_service.py # USD/VND rate fetch + cache logic
│ │ ├── report_service.py # PDF generation (WeasyPrint)
│ │ ├── recurring_service.py # Recurring rule processor
│ │ ├── import_service.py # CSV import parser
│ │ └── snapshot_service.py # Net worth snapshot scheduler
│ │
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ ├── dashboard/
│ │ ├── transactions/
│ │ ├── investments/
│ │ ├── budgets/
│ │ ├── goals/
│ │ ├── accounts/
│ │ ├── reports/
│ │ ├── ai/
│ │ └── settings/
│ │
│ ├── static/
│ │ ├── css/
│ │ ├── js/
│ │ └── img/ # Static images
│ │
│ └── utils/
│ ├── decorators.py # Auth required, etc.
│ ├── formatters.py # Currency, date formatting
│ └── validators.py
├── migrations/ # Flask-Migrate (Alembic)
├── scripts/
│ ├── init_db.py # First-run DB setup + seed categories
│ ├── daily_snapshot.py # Cron: net worth snapshot
│ ├── daily_ai_insight.py # Cron: generate AI summary
│ ├── process_recurring.py # Cron: create recurring transactions
│ ├── fetch_prices.py # Cron: update investment prices
│ └── fetch_fx_rate.py # Cron: fetch daily USD/VND rate
├── tests/
├── .env # Secrets (not committed)
├── .env.example
├── requirements.txt
├── wsgi.py
├── CLAUDE.md # This file
└── deploy.md # Server setup guide
```
---
## 5. Python Dependencies (`requirements.txt`)
```
flask
flask-sqlalchemy
flask-login
flask-migrate
flask-wtf
pymysql
python-dotenv
gunicorn
groq # Groq official Python SDK
yfinance # Investment price fetching
weasyprint # PDF report generation
openpyxl # Excel export
Pillow # Receipt image processing
apscheduler # In-process scheduler (alternative to cron)
```
---
## 6. AI Integration — Groq API (Free Tier)
### Why Groq
- Free tier: 14,400 requests/day, 500,000 tokens/minute
- Fastest inference available (LPU hardware) — responses feel instant
- No local GPU/RAM needed
- Models: `llama-3.3-70b-versatile` (best quality), `llama-3.1-8b-instant` (fastest)
### How It Works
```
User sends chat message
→ ai_service.py builds context (last 90 days transactions summary)
→ POST to Groq API (`/chat/completions`) with Bearer token
→ Stream response back via SSE to browser
→ Response stored in ai_insights table
```
### Context Injection Strategy
```python
# ai_service.py builds a prompt like:
"""
You are a personal finance assistant. Here is the user's financial data:
CURRENT MONTH SUMMARY:
- Total Income: $X
- Total Expenses: $Y
- Top spending categories: Food ($A), Transport ($B), ...
- Budget alerts: Entertainment 92% used
RECENT TRANSACTIONS (last 20):
[date] [category] [amount] [description]
...
NET WORTH: $Z
ACTIVE GOALS: Vacation Fund ($1,200 / $3,000)
User question: {user_message}
Answer concisely and specifically based on the data above.
"""
```
### Fallback
If Groq API key missing/invalid → show friendly message "AI assistant unavailable. Check GROQ_API_KEY in settings."
If Groq rate limit hit → show "AI rate limit reached. Try again shortly."
---
## 7. USD → VND Exchange Rate
> **Reference widget only** — independent of the app's transaction currency. All income/expenses/investments use the single configured app currency. This widget is informational display only.
### Data Source — Free, No API Key Required
Primary: **ExchangeRate-API open endpoint**
```
https://open.er-api.com/v6/latest/USD
```
Returns JSON with all rates including VND. Free tier, no key, 1,500 req/month.
Fallback: **Vietcombank (VCB) rate scrape**
```
https://www.vietcombank.com.vn/en/KHCN/Chuyen-trang-KHCN/Pages/ty-gia.aspx
```
Scrape VCB's official buying/selling rate as backup.
### DB Table: `fx_rates`
```sql
id INT AUTO_INCREMENT PRIMARY KEY
date DATE NOT NULL UNIQUE -- one record per day
usd_to_vnd DECIMAL(12,2) NOT NULL -- e.g. 25,450.00
source VARCHAR(50) -- 'exchangerate-api' | 'vcb' | 'manual'
fetched_at DATETIME
```
### `fx_service.py` Logic
```
get_today_rate():
1. Check fx_rates table for today's date
2. If found → return cached rate (no API call)
3. If not found → fetch from ExchangeRate-API
4. If API fails → try VCB scrape
5. If both fail → return last known rate from DB + show "rate may be outdated" flag
6. Save new rate to DB
```
### Dashboard Widget Display
- Card on dashboard header area (top bar or sidebar)
- Shows: `1 USD = 25,450 ₫` with date label
- Color: neutral/info (blue or grey — not green/red, it's informational)
- Click → opens 30-day rate history mini-chart (Chart.js, line chart)
- Stale indicator: if rate is >1 day old, show small warning icon
### 30-Day Rate History
- Stored in `fx_rates` table (one row/day, auto-accumulates)
- Chart available on dashboard click or Reports page
- Shows trend: flat/up/down with % change label
### Scheduled Job
- Daily 8AM fetch (after markets open in Vietnam)
- `scripts/fetch_fx_rate.py`
- systemd timer unit: `pfm-fxrate.timer`
---
## 8. UI/UX Design Direction
- **Style**: Clean financial dashboard — dark sidebar, white/light content area
- **Charts**: Chart.js (CDN, no build step needed)
- **Tables**: DataTables.js for sortable/searchable transaction tables
- **Forms**: WTForms + Bootstrap 5
- **Icons**: Bootstrap Icons or Feather Icons
- **Color scheme**: Deep navy sidebar, white cards, green (income), red (expense), blue (investment)
- **Mobile**: Responsive (Bootstrap grid)
- **AI Chat**: Floating chat panel (slide-in from right), SSE streaming text
---
## 9. Authentication
- Single-user app (self-hosted)
- Flask-Login with username/password
- Session-based auth
- Password hashed with Werkzeug (bcrypt)
- Optional: IP whitelist via Nginx (allow only LAN access)
---
## 10. Scheduled Jobs (systemd timers or APScheduler)
| Job | Schedule | Script |
|-----|----------|--------|
| Process recurring transactions | Daily 6AM | `process_recurring.py` |
| Fetch USD/VND exchange rate | Daily 8AM | `fetch_fx_rate.py` |
| Fetch investment prices | Daily 4PM | `fetch_prices.py` |
| Save net worth snapshot | 1st of month | `daily_snapshot.py` |
| Generate AI daily insight | Daily midnight | `daily_ai_insight.py` |
Recommend **APScheduler** inside Flask app (simpler) OR separate systemd timer units (more robust).
---
## 11. Development Phases
### Phase 1 — Foundation
- [ ] Flask app factory + config
- [ ] MySQL models + migrations
- [ ] Auth (login/logout)
- [ ] Base template + sidebar nav
### Phase 2 — Core Transactions
- [ ] Accounts CRUD
- [ ] Categories CRUD
- [ ] Transaction entry (income + expense)
- [ ] Transaction list with filter/search
- [ ] Dashboard basics (totals, recent feed)
- [ ] USD→VND rate widget + `fx_service.py` + daily fetch job
### Phase 3 — Budget & Goals
- [ ] Budget planner (set limits)
- [ ] Budget vs actual comparison
- [ ] Goals CRUD + contribution tracking
### Phase 4 — Investments
- [ ] Holdings CRUD
- [ ] Buy/sell transaction log
- [ ] yfinance price auto-fetch
- [ ] Portfolio charts
### Phase 5 — AI Assistant
- [ ] Groq API integration + context builder
- [ ] Chat UI with SSE streaming
- [ ] Daily auto-insight cron
### Phase 6 — Reports & Export
- [ ] Monthly summary page
- [ ] PDF export (WeasyPrint)
- [ ] CSV/Excel export
- [ ] Net worth history chart
### Phase 7 — Polish
- [ ] Recurring transaction engine
- [ ] Receipt upload
- [ ] CSV import
- [ ] Budget alerts + notifications
- [ ] Mobile responsiveness pass
---
## 12. 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/app/uploads
MAX_CONTENT_LENGTH=10485760
FLASK_ENV=production
APP_CURRENCY=USD # Single currency for all transactions (configurable)
APP_CURRENCY_SYMBOL=$ # Display symbol
APP_TIMEZONE=Asia/Ho_Chi_Minh # Server timezone for scheduled jobs
```
---
## 13. Security Notes
- All routes protected by `@login_required`
- CSRF protection via Flask-WTF
- SQL injection prevented by SQLAlchemy ORM
- File upload validation (type + size limit)
- Nginx: restrict access to local network if desired
- HTTPS via Let's Encrypt (Certbot) — `pfm.ngodanguyen.tech`
- Groq API receives only anonymized transaction summaries (no account names/personal details in prompt)
- GROQ_API_KEY stored in `.env`, never exposed to frontend
---
## 14. Future Enhancements (Post-MVP)
- Mobile app companion (iOS — fits your skill set)
- Bank statement auto-import (parse PDF bank statements)
- Multi-currency with live FX rates (via free API)
- Expense photo OCR (extract amount from receipt image via Groq vision model)
- Email/SMS alerts (integrate Twilio — you already know it from FaxDesk)
- Shared household mode (2 users)
+55
View File
@@ -0,0 +1,55 @@
import os
from flask import Flask
from app.config import config
from app.extensions import db, login_manager, migrate, csrf
from app.utils.formatters import format_currency, format_percent, format_large_number
def create_app(config_name=None):
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
if config_name == 'production':
config_name = 'production'
else:
config_name = 'development'
app = Flask(__name__)
app.config.from_object(config[config_name])
# Init extensions
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
csrf.init_app(app)
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.dashboard import dashboard_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
# Import all models so Flask-Migrate can see them
with app.app_context():
from app.models import (
User, Account, Category, Receipt, RecurringRule,
Transaction, Budget, Goal, GoalContribution,
Investment, InvestmentTransaction, NetWorthSnapshot,
AiInsight, FxRate
)
# Jinja2 template globals
app.jinja_env.globals['format_currency'] = format_currency
app.jinja_env.globals['format_percent'] = format_percent
app.jinja_env.globals['format_large_number'] = format_large_number
# Jinja2 filters
@app.template_filter('currency')
def currency_filter(value, symbol=None):
return format_currency(value, symbol)
@app.template_filter('percent')
def percent_filter(value):
return format_percent(value)
return app
+55
View File
@@ -0,0 +1,55 @@
import os
from flask import Flask
from app.config import config
from app.extensions import db, login_manager, migrate, csrf
from app.utils.formatters import format_currency, format_percent, format_large_number
def create_app(config_name=None):
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
if config_name == 'production':
config_name = 'production'
else:
config_name = 'development'
app = Flask(__name__)
app.config.from_object(config[config_name])
# Init extensions
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
csrf.init_app(app)
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.dashboard import dashboard_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
# Import all models so Flask-Migrate can see them
with app.app_context():
from app.models import (
User, Account, Category, Receipt, RecurringRule,
Transaction, Budget, Goal, GoalContribution,
Investment, InvestmentTransaction, NetWorthSnapshot,
AiInsight, FxRate
)
# Jinja2 template globals
app.jinja_env.globals['format_currency'] = format_currency
app.jinja_env.globals['format_percent'] = format_percent
app.jinja_env.globals['format_large_number'] = format_large_number
# Jinja2 filters
@app.template_filter('currency')
def currency_filter(value, symbol=None):
return format_currency(value, symbol)
@app.template_filter('percent')
def percent_filter(value):
return format_percent(value)
return app
+45
View File
@@ -0,0 +1,45 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me')
SQLALCHEMY_TRACK_MODIFICATIONS = False
WTF_CSRF_ENABLED = True
DATABASE_URL = os.environ.get('DATABASE_URL', '')
SQLALCHEMY_DATABASE_URI = DATABASE_URL
GROQ_API_KEY = os.environ.get('GROQ_API_KEY', '')
GROQ_MODEL = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 10485760))
APP_CURRENCY = os.environ.get('APP_CURRENCY', 'USD')
APP_CURRENCY_SYMBOL = os.environ.get('APP_CURRENCY_SYMBOL', '$')
APP_TIMEZONE = os.environ.get('APP_TIMEZONE', 'Asia/Ho_Chi_Minh')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
'mysql+pymysql://pfm_user:password@localhost/pfm_db'
)
SQLALCHEMY_ECHO = False # set True to log SQL queries during dev
class ProductionConfig(Config):
DEBUG = False
SQLALCHEMY_ECHO = False
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig,
}
+13
View File
@@ -0,0 +1,13 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_wtf.csrf import CSRFProtect
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()
csrf = CSRFProtect()
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'warning'
+12
View File
@@ -0,0 +1,12 @@
from app.models.user import User
from app.models.account import Account
from app.models.category import Category
from app.models.receipt import Receipt
from app.models.recurring_rule import RecurringRule
from app.models.transaction import Transaction
from app.models.budget import Budget
from app.models.goal import Goal, GoalContribution
from app.models.investment import Investment, InvestmentTransaction
from app.models.net_worth_snapshot import NetWorthSnapshot
from app.models.ai_insight import AiInsight
from app.models.fx_rate import FxRate
+32
View File
@@ -0,0 +1,32 @@
from app.extensions import db
from datetime import datetime
class Account(db.Model):
__tablename__ = 'accounts'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
account_type = db.Column(
db.Enum('checking', 'savings', 'cash', 'credit_card', 'crypto', 'investment', 'other'),
nullable=False,
default='checking'
)
balance = db.Column(db.Numeric(15, 2), default=0.00, nullable=False)
credit_limit = db.Column(db.Numeric(15, 2), nullable=True) # credit cards
due_date = db.Column(db.Integer, nullable=True) # day of month bill due
color = db.Column(db.String(7), default='#4F81C7') # hex color for UI
icon = db.Column(db.String(50), default='bi-bank') # bootstrap icon name
is_active = db.Column(db.Boolean, default=True)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
transactions = db.relationship('Transaction', back_populates='account',
lazy='dynamic', foreign_keys='Transaction.account_id')
transfer_transactions = db.relationship('Transaction', back_populates='to_account',
lazy='dynamic', foreign_keys='Transaction.to_account_id')
def __repr__(self):
return f'<Account {self.name}>'
+22
View File
@@ -0,0 +1,22 @@
from app.extensions import db
from datetime import datetime
class AiInsight(db.Model):
__tablename__ = 'ai_insights'
id = db.Column(db.Integer, primary_key=True)
insight_date = db.Column(db.Date, nullable=False, index=True)
insight_type = db.Column(
db.Enum('daily_summary', 'weekly_summary', 'chat_response', 'alert'),
nullable=False,
default='daily_summary'
)
prompt_summary = db.Column(db.Text, nullable=True) # what context was sent
content = db.Column(db.Text, nullable=False) # AI response
model_used = db.Column(db.String(64), nullable=True)
tokens_used = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<AiInsight {self.insight_type} {self.insight_date}>'
+23
View File
@@ -0,0 +1,23 @@
from app.extensions import db
from datetime import datetime
class Budget(db.Model):
__tablename__ = 'budgets'
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
month = db.Column(db.String(7), nullable=False, index=True) # YYYY-MM
limit_amount = db.Column(db.Numeric(15, 2), nullable=False)
rollover_enabled = db.Column(db.Boolean, default=False)
rollover_amount = db.Column(db.Numeric(15, 2), default=0.00)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
category = db.relationship('Category')
__table_args__ = (
db.UniqueConstraint('category_id', 'month', name='uq_budget_category_month'),
)
def __repr__(self):
return f'<Budget {self.month} cat={self.category_id} limit={self.limit_amount}>'
+28
View File
@@ -0,0 +1,28 @@
from app.extensions import db
from datetime import datetime
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
category_type = db.Column(
db.Enum('income', 'expense', 'both'),
nullable=False,
default='expense'
)
parent_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
color = db.Column(db.String(7), default='#6B7280')
icon = db.Column(db.String(50), default='bi-tag')
is_system = db.Column(db.Boolean, default=False) # system categories can't be deleted
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Self-referential relationship for subcategories
subcategories = db.relationship('Category', backref=db.backref('parent', remote_side=[id]),
lazy='dynamic')
transactions = db.relationship('Transaction', back_populates='category', lazy='dynamic')
def __repr__(self):
return f'<Category {self.name}>'
+15
View File
@@ -0,0 +1,15 @@
from app.extensions import db
from datetime import datetime
class FxRate(db.Model):
__tablename__ = 'fx_rates'
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.Date, nullable=False, unique=True, index=True)
usd_to_vnd = db.Column(db.Numeric(12, 2), nullable=False)
source = db.Column(db.String(50), nullable=True) # 'exchangerate-api' | 'vcb' | 'manual'
fetched_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<FxRate {self.date} 1USD={self.usd_to_vnd}VND>'
+50
View File
@@ -0,0 +1,50 @@
from app.extensions import db
from datetime import datetime
class Goal(db.Model):
__tablename__ = 'goals'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True)
target_amount = db.Column(db.Numeric(15, 2), nullable=False)
current_amount = db.Column(db.Numeric(15, 2), default=0.00)
target_date = db.Column(db.Date, nullable=True)
linked_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True)
color = db.Column(db.String(7), default='#10B981')
icon = db.Column(db.String(50), default='bi-piggy-bank')
is_completed = db.Column(db.Boolean, default=False)
completed_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
linked_account = db.relationship('Account')
contributions = db.relationship('GoalContribution', back_populates='goal',
lazy='dynamic', cascade='all, delete-orphan')
@property
def progress_percent(self):
if self.target_amount <= 0:
return 0
pct = (float(self.current_amount) / float(self.target_amount)) * 100
return min(round(pct, 1), 100.0)
def __repr__(self):
return f'<Goal {self.name}>'
class GoalContribution(db.Model):
__tablename__ = 'goal_contributions'
id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(db.Integer, db.ForeignKey('goals.id'), nullable=False)
amount = db.Column(db.Numeric(15, 2), nullable=False)
notes = db.Column(db.String(255), nullable=True)
date = db.Column(db.Date, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
goal = db.relationship('Goal', back_populates='contributions')
def __repr__(self):
return f'<GoalContribution goal={self.goal_id} amount={self.amount}>'
+69
View File
@@ -0,0 +1,69 @@
from app.extensions import db
from datetime import datetime
class Investment(db.Model):
__tablename__ = 'investments'
id = db.Column(db.Integer, primary_key=True)
asset_name = db.Column(db.String(100), nullable=False)
ticker = db.Column(db.String(20), nullable=True)
asset_type = db.Column(
db.Enum('stock', 'etf', 'crypto', 'real_estate', 'bond', 'cash', 'other'),
nullable=False,
default='stock'
)
shares = db.Column(db.Numeric(18, 8), nullable=False, default=0)
avg_cost_basis = db.Column(db.Numeric(15, 4), nullable=False, default=0)
current_price = db.Column(db.Numeric(15, 4), nullable=True)
last_price_update = db.Column(db.DateTime, nullable=True)
notes = db.Column(db.Text, nullable=True)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
inv_transactions = db.relationship('InvestmentTransaction', back_populates='investment',
lazy='dynamic', cascade='all, delete-orphan')
@property
def total_cost(self):
return float(self.shares) * float(self.avg_cost_basis)
@property
def current_value(self):
if self.current_price:
return float(self.shares) * float(self.current_price)
return self.total_cost
@property
def unrealized_gain(self):
return self.current_value - self.total_cost
@property
def unrealized_gain_pct(self):
if self.total_cost == 0:
return 0
return round((self.unrealized_gain / self.total_cost) * 100, 2)
def __repr__(self):
return f'<Investment {self.asset_name}>'
class InvestmentTransaction(db.Model):
__tablename__ = 'investment_transactions'
id = db.Column(db.Integer, primary_key=True)
investment_id = db.Column(db.Integer, db.ForeignKey('investments.id'), nullable=False)
transaction_type = db.Column(db.Enum('buy', 'sell', 'dividend', 'split'), nullable=False)
shares = db.Column(db.Numeric(18, 8), nullable=False)
price_per_share = db.Column(db.Numeric(15, 4), nullable=False)
total_amount = db.Column(db.Numeric(15, 2), nullable=False)
fees = db.Column(db.Numeric(10, 2), default=0.00)
date = db.Column(db.Date, nullable=False, index=True)
notes = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
investment = db.relationship('Investment', back_populates='inv_transactions')
def __repr__(self):
return f'<InvTransaction {self.transaction_type} {self.shares}@{self.price_per_share}>'
+18
View File
@@ -0,0 +1,18 @@
from app.extensions import db
from datetime import datetime
class NetWorthSnapshot(db.Model):
__tablename__ = 'net_worth_snapshots'
id = db.Column(db.Integer, primary_key=True)
snapshot_date = db.Column(db.Date, nullable=False, unique=True, index=True)
total_assets = db.Column(db.Numeric(15, 2), nullable=False, default=0)
total_liabilities = db.Column(db.Numeric(15, 2), nullable=False, default=0)
net_worth = db.Column(db.Numeric(15, 2), nullable=False, default=0)
account_balances = db.Column(db.JSON, nullable=True) # snapshot of each account balance
investment_value = db.Column(db.Numeric(15, 2), default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<NetWorthSnapshot {self.snapshot_date} nw={self.net_worth}>'
+18
View File
@@ -0,0 +1,18 @@
from app.extensions import db
from datetime import datetime
class Receipt(db.Model):
__tablename__ = 'receipts'
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
file_size = db.Column(db.Integer, nullable=True)
mime_type = db.Column(db.String(50), nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
transaction = db.relationship('Transaction', back_populates='receipt', uselist=False)
def __repr__(self):
return f'<Receipt {self.filename}>'
+35
View File
@@ -0,0 +1,35 @@
from app.extensions import db
from datetime import datetime
class RecurringRule(db.Model):
__tablename__ = 'recurring_rules'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
transaction_type = db.Column(
db.Enum('income', 'expense'),
nullable=False
)
amount = db.Column(db.Numeric(15, 2), nullable=False)
description = db.Column(db.String(255), nullable=False)
frequency = db.Column(
db.Enum('daily', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'),
nullable=False,
default='monthly'
)
start_date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=True)
last_run = db.Column(db.Date, nullable=True)
next_run = db.Column(db.Date, nullable=True, index=True)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
transactions = db.relationship('Transaction', back_populates='recurring_rule', lazy='dynamic')
account = db.relationship('Account')
category = db.relationship('Category')
def __repr__(self):
return f'<RecurringRule {self.name} {self.frequency}>'
+37
View File
@@ -0,0 +1,37 @@
from app.extensions import db
from datetime import datetime
class Transaction(db.Model):
__tablename__ = 'transactions'
id = db.Column(db.Integer, primary_key=True)
account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=False)
to_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True) # transfers
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
recurring_rule_id = db.Column(db.Integer, db.ForeignKey('recurring_rules.id'), nullable=True)
receipt_id = db.Column(db.Integer, db.ForeignKey('receipts.id'), nullable=True)
transaction_type = db.Column(
db.Enum('income', 'expense', 'transfer'),
nullable=False
)
amount = db.Column(db.Numeric(15, 2), nullable=False)
description = db.Column(db.String(255), nullable=False)
notes = db.Column(db.Text, nullable=True)
date = db.Column(db.Date, nullable=False, index=True)
is_recurring = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
account = db.relationship('Account', back_populates='transactions',
foreign_keys=[account_id])
to_account = db.relationship('Account', back_populates='transfer_transactions',
foreign_keys=[to_account_id])
category = db.relationship('Category', back_populates='transactions')
receipt = db.relationship('Receipt', back_populates='transaction')
recurring_rule = db.relationship('RecurringRule', back_populates='transactions')
def __repr__(self):
return f'<Transaction {self.transaction_type} {self.amount} on {self.date}>'
+34
View File
@@ -0,0 +1,34 @@
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app.extensions import db, login_manager
from datetime import datetime
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=True)
password_hash = db.Column(db.String(256), nullable=False)
display_name = db.Column(db.String(100), nullable=True)
timezone = db.Column(db.String(64), default='Asia/Ho_Chi_Minh')
currency = db.Column(db.String(10), default='USD')
currency_symbol = db.Column(db.String(5), default='$')
groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime, nullable=True)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
View File
+44
View File
@@ -0,0 +1,44 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
from app.models.user import User
from app.extensions import db
from datetime import datetime
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(1, 64)])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember me')
submit = SubmitField('Sign In')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('dashboard.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data.strip()).first()
if user and user.check_password(form.password.data):
login_user(user, remember=form.remember_me.data)
user.last_login = datetime.utcnow()
db.session.commit()
next_page = request.args.get('next')
return redirect(next_page or url_for('dashboard.index'))
flash('Invalid username or password.', 'danger')
return render_template('auth/login.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
+10
View File
@@ -0,0 +1,10 @@
from flask import Blueprint, render_template
from flask_login import login_required
dashboard_bp = Blueprint('dashboard', __name__)
@dashboard_bp.route('/')
@login_required
def index():
return render_template('dashboard/index.html')
+251
View File
@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In — PFM</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@500&display=swap" rel="stylesheet">
<style>
:root {
--accent: #3b82f6;
--accent-dark: #1d4ed8;
--bg: #0f172a;
--card-bg: #1e293b;
--border: #334155;
--text: #f1f5f9;
--muted: #94a3b8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DM Sans', sans-serif;
background: var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* Background grid pattern */
body::before {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(59,130,246,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(59,130,246,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
/* Glow blob */
body::after {
content: '';
position: absolute;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(59,130,246,0.12) 0%, transparent 70%);
pointer-events: none;
}
.login-wrap {
width: 100%;
max-width: 400px;
padding: 20px;
position: relative;
z-index: 1;
}
.login-brand {
text-align: center;
margin-bottom: 32px;
}
.login-brand .brand-icon {
width: 52px;
height: 52px;
background: var(--accent);
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #fff;
margin-bottom: 14px;
box-shadow: 0 0 0 6px rgba(59,130,246,0.15);
}
.login-brand h1 {
font-size: 22px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
}
.login-brand p {
font-size: 13.5px;
color: var(--muted);
margin-top: 4px;
}
.login-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--muted);
margin-bottom: 6px;
}
.form-control {
background: #0f172a;
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: 10px 14px;
font-size: 14px;
font-family: 'DM Sans', sans-serif;
transition: all 0.2s;
}
.form-control:focus {
background: #0f172a;
border-color: var(--accent);
color: var(--text);
box-shadow: 0 0 0 3px rgba(59,130,246,0.2);
}
.form-control::placeholder { color: #475569; }
.input-group-text {
background: #0f172a;
border-color: var(--border);
color: #475569;
}
.btn-login {
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 11px;
font-size: 14px;
font-weight: 500;
width: 100%;
cursor: pointer;
transition: all 0.2s;
font-family: 'DM Sans', sans-serif;
letter-spacing: 0.02em;
}
.btn-login:hover {
background: var(--accent-dark);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(59,130,246,0.35);
}
.btn-login:active { transform: translateY(0); }
.form-check-label { font-size: 13px; color: var(--muted); }
.form-check-input:checked { background-color: var(--accent); border-color: var(--accent); }
.alert {
border-radius: 8px;
font-size: 13.5px;
padding: 10px 14px;
}
.alert-danger { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
.login-footer {
text-align: center;
margin-top: 20px;
font-size: 12px;
color: #334155;
}
</style>
</head>
<body>
<div class="login-wrap">
<div class="login-brand">
<div class="brand-icon"><i class="bi bi-currency-exchange"></i></div>
<h1>Personal Finance</h1>
<p>Sign in to your dashboard</p>
</div>
<div class="login-card">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} mb-3">
<i class="bi bi-exclamation-circle me-1"></i>{{ message }}
</div>
{% endfor %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else ""),
placeholder="Username", autocomplete="username") }}
</div>
{% for error in form.username.errors %}
<div class="text-danger mt-1" style="font-size:12px;">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-4">
{{ form.password.label(class="form-label") }}
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else ""),
placeholder="Password", autocomplete="current-password",
id="passwordInput") }}
<button type="button" class="input-group-text" style="cursor:pointer;"
onclick="togglePassword()">
<i class="bi bi-eye" id="pwdToggleIcon"></i>
</button>
</div>
{% for error in form.password.errors %}
<div class="text-danger mt-1" style="font-size:12px;">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-4">
<div class="form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
</div>
<button type="submit" class="btn-login">
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
</button>
</form>
</div>
<div class="login-footer">Self-hosted · Private · Secure</div>
</div>
<script>
function togglePassword() {
const input = document.getElementById('passwordInput');
const icon = document.getElementById('pwdToggleIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
}
</script>
</body>
</html>
+471
View File
@@ -0,0 +1,471 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PFM{% endblock %} — Personal Finance</title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--sidebar-bg: #0f172a;
--sidebar-text: #94a3b8;
--sidebar-text-active: #f1f5f9;
--sidebar-hover-bg: #1e293b;
--sidebar-active-bg: #1e3a5f;
--sidebar-accent: #3b82f6;
--topbar-height: 56px;
--body-bg: #f8fafc;
--card-bg: #ffffff;
--text-primary: #0f172a;
--text-muted: #64748b;
--border-color: #e2e8f0;
--income-color: #10b981;
--expense-color: #ef4444;
--investment-color: #3b82f6;
--transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
* { box-sizing: border-box; }
body {
font-family: 'DM Sans', sans-serif;
background: var(--body-bg);
color: var(--text-primary);
margin: 0;
overflow-x: hidden;
}
/* ── SIDEBAR ──────────────────────────────── */
#sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: var(--sidebar-width);
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
z-index: 1040;
transition: var(--transition);
overflow: hidden;
}
#sidebar.collapsed {
width: var(--sidebar-collapsed-width);
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
height: var(--topbar-height);
border-bottom: 1px solid #1e293b;
white-space: nowrap;
overflow: hidden;
flex-shrink: 0;
}
.sidebar-brand .brand-icon {
width: 32px;
height: 32px;
background: var(--sidebar-accent);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
color: #fff;
}
.sidebar-brand .brand-text {
font-size: 15px;
font-weight: 600;
color: var(--sidebar-text-active);
letter-spacing: 0.02em;
transition: var(--transition);
opacity: 1;
}
#sidebar.collapsed .brand-text { opacity: 0; width: 0; }
.sidebar-nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar { display: none; }
.nav-section-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #475569;
padding: 16px 20px 4px;
white-space: nowrap;
transition: var(--transition);
}
#sidebar.collapsed .nav-section-label { opacity: 0; }
.sidebar-link {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 16px;
color: var(--sidebar-text);
text-decoration: none;
font-size: 13.5px;
font-weight: 400;
border-radius: 6px;
margin: 1px 8px;
white-space: nowrap;
overflow: hidden;
transition: var(--transition);
position: relative;
}
.sidebar-link:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-active);
}
.sidebar-link.active {
background: var(--sidebar-active-bg);
color: var(--sidebar-text-active);
font-weight: 500;
}
.sidebar-link.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: var(--sidebar-accent);
border-radius: 0 2px 2px 0;
}
.sidebar-link i {
font-size: 16px;
flex-shrink: 0;
width: 20px;
text-align: center;
}
.sidebar-link .link-text {
opacity: 1;
transition: var(--transition);
}
#sidebar.collapsed .link-text { opacity: 0; width: 0; overflow: hidden; }
.sidebar-footer {
padding: 8px;
border-top: 1px solid #1e293b;
flex-shrink: 0;
}
/* ── TOPBAR ───────────────────────────────── */
#topbar {
position: fixed;
top: 0;
left: var(--sidebar-width);
right: 0;
height: var(--topbar-height);
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 20px;
z-index: 1030;
gap: 12px;
transition: var(--transition);
}
#sidebar.collapsed ~ #topbar,
#sidebar.collapsed ~ * #topbar {
left: var(--sidebar-collapsed-width);
}
.topbar-toggle {
background: none;
border: none;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
padding: 4px 6px;
border-radius: 6px;
line-height: 1;
transition: var(--transition);
}
.topbar-toggle:hover { background: var(--border-color); color: var(--text-primary); }
.topbar-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.topbar-right {
display: flex;
align-items: center;
gap: 8px;
}
/* ── MAIN CONTENT ─────────────────────────── */
#main-content {
margin-left: var(--sidebar-width);
margin-top: var(--topbar-height);
padding: 24px;
min-height: calc(100vh - var(--topbar-height));
transition: var(--transition);
}
#sidebar.collapsed ~ #main-content {
margin-left: var(--sidebar-collapsed-width);
}
/* ── CARDS ────────────────────────────────── */
.pfm-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.pfm-card-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 8px;
}
/* ── FLASH MESSAGES ───────────────────────── */
.flash-container {
position: fixed;
top: calc(var(--topbar-height) + 12px);
right: 16px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 360px;
}
/* ── RESPONSIVE ───────────────────────────── */
@media (max-width: 768px) {
#sidebar {
transform: translateX(-100%);
width: var(--sidebar-width) !important;
}
#sidebar.mobile-open {
transform: translateX(0);
}
#topbar {
left: 0 !important;
}
#main-content {
margin-left: 0 !important;
}
.sidebar-overlay {
display: block;
}
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 1039;
}
.sidebar-overlay.active { display: block; }
/* ── UTILITIES ────────────────────────────── */
.text-income { color: var(--income-color) !important; }
.text-expense { color: var(--expense-color) !important; }
.text-invest { color: var(--investment-color) !important; }
.badge-income { background: #d1fae5; color: #065f46; }
.badge-expense { background: #fee2e2; color: #991b1b; }
.badge-transfer { background: #dbeafe; color: #1e40af; }
code, .mono { font-family: 'DM Mono', monospace; }
{% block extra_css %}{% endblock %}
</style>
</head>
<body>
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- ── SIDEBAR ────────────────────────────────────── -->
<nav id="sidebar">
<div class="sidebar-brand">
<div class="brand-icon"><i class="bi bi-currency-exchange"></i></div>
<span class="brand-text">PFM</span>
</div>
<div class="sidebar-nav">
<a href="{{ url_for('dashboard.index') }}"
class="sidebar-link {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
<i class="bi bi-grid-1x2"></i>
<span class="link-text">Dashboard</span>
</a>
<div class="nav-section-label">Money</div>
<a href="#" class="sidebar-link {% if 'transactions' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-arrow-left-right"></i>
<span class="link-text">Transactions</span>
</a>
<a href="#" class="sidebar-link {% if 'income' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-arrow-down-circle"></i>
<span class="link-text">Income</span>
</a>
<a href="#" class="sidebar-link {% if 'expenses' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-arrow-up-circle"></i>
<span class="link-text">Expenses</span>
</a>
<a href="#" class="sidebar-link {% if 'accounts' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-wallet2"></i>
<span class="link-text">Accounts</span>
</a>
<div class="nav-section-label">Planning</div>
<a href="#" class="sidebar-link {% if 'budgets' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-pie-chart"></i>
<span class="link-text">Budgets</span>
</a>
<a href="#" class="sidebar-link {% if 'goals' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-bullseye"></i>
<span class="link-text">Goals</span>
</a>
<div class="nav-section-label">Growth</div>
<a href="#" class="sidebar-link {% if 'investments' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-graph-up-arrow"></i>
<span class="link-text">Investments</span>
</a>
<a href="#" class="sidebar-link {% if 'reports' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-file-earmark-bar-graph"></i>
<span class="link-text">Reports</span>
</a>
<div class="nav-section-label">AI</div>
<a href="#" class="sidebar-link {% if 'ai' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-stars"></i>
<span class="link-text">AI Assistant</span>
</a>
</div>
<div class="sidebar-footer">
<a href="{{ url_for('settings.index') if 'settings' in request.blueprints else '#' }}"
class="sidebar-link {% if 'settings' in request.endpoint|default('') %}active{% endif %}">
<i class="bi bi-gear"></i>
<span class="link-text">Settings</span>
</a>
<a href="{{ url_for('auth.logout') }}" class="sidebar-link">
<i class="bi bi-box-arrow-right"></i>
<span class="link-text">Logout</span>
</a>
</div>
</nav>
<!-- ── TOPBAR ──────────────────────────────────────── -->
<header id="topbar">
<button class="topbar-toggle" id="sidebarToggle" title="Toggle sidebar">
<i class="bi bi-list"></i>
</button>
<span class="topbar-title">{% block page_title %}{% endblock %}</span>
<div class="topbar-right">
<span class="d-none d-sm-inline small text-muted mono">
{{ current_user.display_name or current_user.username }}
</span>
</div>
</header>
<!-- ── FLASH MESSAGES ─────────────────────────────── -->
<div class="flash-container" id="flashContainer">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show shadow-sm mb-0 py-2 px-3"
style="font-size:13.5px; border-radius:8px;" role="alert">
{{ message }}
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
</div>
<!-- ── MAIN CONTENT ───────────────────────────────── -->
<main id="main-content">
{% block content %}{% endblock %}
</main>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
const sidebar = document.getElementById('sidebar');
const toggleBtn = document.getElementById('sidebarToggle');
const overlay = document.getElementById('sidebarOverlay');
const STORAGE_KEY = 'pfm_sidebar_collapsed';
const isMobile = () => window.innerWidth < 769;
// Restore desktop collapsed state
if (!isMobile() && localStorage.getItem(STORAGE_KEY) === '1') {
sidebar.classList.add('collapsed');
}
function toggleSidebar() {
if (isMobile()) {
sidebar.classList.toggle('mobile-open');
overlay.classList.toggle('active');
} else {
sidebar.classList.toggle('collapsed');
localStorage.setItem(STORAGE_KEY, sidebar.classList.contains('collapsed') ? '1' : '0');
}
}
toggleBtn.addEventListener('click', toggleSidebar);
overlay.addEventListener('click', function () {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('active');
});
// Auto-dismiss flash messages after 4s
document.querySelectorAll('#flashContainer .alert').forEach(function (el) {
setTimeout(function () {
const bsAlert = bootstrap.Alert.getOrCreateInstance(el);
bsAlert.close();
}, 4000);
});
})();
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12">
<div class="pfm-card text-center py-5">
<i class="bi bi-grid-1x2 text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">Dashboard</h5>
<p class="text-muted small">Phase 2 will populate this with live data.</p>
</div>
</div>
</div>
{% endblock %}
View File
+13
View File
@@ -0,0 +1,13 @@
from functools import wraps
from flask import redirect, url_for
from flask_login import current_user
def login_required_custom(f):
"""Redundant wrapper — Flask-Login handles this, but kept for explicitness."""
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated
+47
View File
@@ -0,0 +1,47 @@
from flask import current_app
from flask_login import current_user
def format_currency(amount, symbol=None, show_sign=False):
"""Format a number as currency using the app's configured symbol."""
if amount is None:
return ''
try:
amount = float(amount)
except (TypeError, ValueError):
return ''
if symbol is None:
try:
symbol = current_user.currency_symbol if current_user.is_authenticated \
else current_app.config.get('APP_CURRENCY_SYMBOL', '$')
except Exception:
symbol = '$'
formatted = f"{symbol}{abs(amount):,.2f}"
if show_sign:
if amount < 0:
formatted = f"-{formatted}"
elif amount > 0:
formatted = f"+{formatted}"
elif amount < 0:
formatted = f"-{formatted}"
return formatted
def format_percent(value, decimals=1):
if value is None:
return ''
return f"{float(value):.{decimals}f}%"
def format_large_number(value):
"""Abbreviate large numbers: 1,500,000 → 1.5M"""
if value is None:
return ''
value = float(value)
if abs(value) >= 1_000_000:
return f"{value / 1_000_000:.1f}M"
if abs(value) >= 1_000:
return f"{value / 1_000:.1f}K"
return f"{value:.2f}"
+672
View File
@@ -0,0 +1,672 @@
# PFMS — Server Deployment Guide
> Ubuntu Server · Nginx · Gunicorn · MySQL · Groq API
---
## 0. Assumptions
| Item | Value |
|------|-------|
| OS | Ubuntu 22.04 LTS |
| App user | `pfm` |
| App directory | `/home/pfm/app` |
| Domain / IP | `pfm.ngodanguyen.tech` |
| SSL | Let's Encrypt via Certbot |
| MySQL version | 8.0 |
| Python | 3.11+ |
---
## 1. System Prep
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3 python3-pip python3-venv python3-dev \
build-essential libssl-dev libffi-dev \
nginx mysql-server git curl wget \
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \
libffi-dev libjpeg-dev libopenjp2-7-dev
```
> `libpango*` and related libs are required by WeasyPrint for PDF generation.
---
## 2. Create App User
```bash
# Skip if user pfm already exists
id pfm &>/dev/null || sudo useradd -m -s /bin/bash pfm
sudo usermod -aG www-data pfm
```
---
## 3. MySQL Setup
```bash
sudo mysql_secure_installation
# Follow prompts: set root password, remove anonymous users, disable remote root
```
```bash
sudo mysql -u root -p
```
```sql
CREATE DATABASE pfm_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'pfm_user'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD_HERE';
GRANT ALL PRIVILEGES ON pfm_db.* TO 'pfm_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
```
---
## 4. App Directory & Virtual Environment
```bash
sudo mkdir -p /home/pfm/app
sudo chown pfm:www-data /home/pfm/app
sudo chmod 750 /home/pfm/app
sudo -u pfm bash
cd /home/pfm/app
python3 -m venv venv
source venv/bin/activate
# Clone from your Gitea
git clone https://gitea.yourdomain.com/youruser/pfm.git .
pip install --upgrade pip
pip install -r requirements.txt
```
---
## 5. Environment File
```bash
sudo -u pfm nano /home/pfm/app/.env
```
```env
SECRET_KEY=generate-a-long-random-string-here
DATABASE_URL=mysql+pymysql://pfm_user:STRONG_PASSWORD_HERE@localhost/pfm_db
GROQ_API_KEY=your-groq-api-key-here
GROQ_MODEL=llama-3.3-70b-versatile
UPLOAD_FOLDER=/home/pfm/app/uploads
MAX_CONTENT_LENGTH=10485760
FLASK_ENV=production
APP_CURRENCY=USD
APP_CURRENCY_SYMBOL=$
APP_TIMEZONE=Asia/Ho_Chi_Minh
```
```bash
chmod 600 /home/pfm/app/.env
```
Generate a secret key:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
---
## 6. Initialize Database
```bash
cd /home/pfm/app
source venv/bin/activate
flask db upgrade # Run all migrations
python scripts/init_db.py # Seed default categories + create admin user
```
---
## 7. Upload Directory
```bash
sudo mkdir -p /home/pfm/app/uploads
sudo chown pfm:www-data /home/pfm/app/uploads
sudo chmod 750 /home/pfm/app/uploads
```
---
## 8. Gunicorn Setup (systemd service)
```bash
sudo nano /etc/systemd/system/pfm.service
```
```ini
[Unit]
Description=PFMS — Personal Finance Management System
After=network.target mysql.service
[Service]
User=pfm
Group=www-data
WorkingDirectory=/home/pfm/app
Environment="PATH=/home/pfm/app/venv/bin"
EnvironmentFile=/home/pfm/app/.env
ExecStart=/home/pfm/app/venv/bin/gunicorn \
--workers 2 \
--bind unix:/run/pfm/pfm.sock \
--timeout 120 \
--access-logfile /var/log/pfm/access.log \
--error-logfile /var/log/pfm/error.log \
wsgi:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5
RuntimeDirectory=pfm
RuntimeDirectoryMode=0755
[Install]
WantedBy=multi-user.target
```
```bash
# Create log directory
sudo mkdir -p /var/log/pfm
sudo chown pfm:www-data /var/log/pfm
sudo systemctl daemon-reload
sudo systemctl enable pfm
sudo systemctl start pfm
sudo systemctl status pfm
```
---
## 9. Nginx Configuration
```bash
sudo nano /etc/nginx/sites-available/pfm
```
```nginx
server {
listen 80;
server_name pfm.ngodanguyen.tech;
# Certbot will update this block automatically
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name pfm.ngodanguyen.tech;
# Certbot will populate these after running certbot
# ssl_certificate /etc/letsencrypt/live/pfm.ngodanguyen.tech/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/pfm.ngodanguyen.tech/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 10M;
location / {
proxy_pass http://unix:/run/pfm/pfm.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
# AI chat uses SSE — disable buffering
location /ai/stream {
proxy_pass http://unix:/run/pfm/pfm.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Cache-Control no-cache;
proxy_set_header X-Accel-Buffering no;
proxy_buffering off;
proxy_read_timeout 300s;
chunked_transfer_encoding on;
}
location /static/ {
alias /home/pfm/app/app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /uploads/ {
alias /home/pfm/app/uploads/;
internal;
}
access_log /var/log/nginx/pfm_access.log;
error_log /var/log/nginx/pfm_error.log;
}
```
```bash
sudo ln -s /etc/nginx/sites-available/pfm /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
---
## 9a. SSL — Certbot (Let's Encrypt)
```bash
# Install Certbot + Nginx plugin
sudo apt install -y certbot python3-certbot-nginx
# Issue certificate — auto-edits the Nginx config above
sudo certbot --nginx -d pfm.ngodanguyen.tech
# Follow prompts:
# - Enter email for renewal notices
# - Agree to TOS
# - Choose redirect HTTP → HTTPS (recommended)
# Verify cert
sudo certbot certificates
```
Certbot automatically:
- Populates the `ssl_certificate` lines in the Nginx config
- Sets up HTTP → HTTPS redirect
- Installs a systemd timer for auto-renewal
```bash
# Test auto-renewal
sudo certbot renew --dry-run
# Check renewal timer
sudo systemctl status certbot.timer
```
---
## 10. Groq API Setup (Free AI)
No local installation needed. Groq runs in the cloud — just get a free API key.
### 10.1 Get API Key
1. Sign up at **https://console.groq.com**
2. Go to **API Keys** → **Create API key**
3. Copy the key into `/home/pfm/app/.env` as `GROQ_API_KEY`
### 10.2 Free Tier Limits
| Metric | Limit |
|--------|-------|
| Requests/day | 14,400 |
| Tokens/minute | 500,000 |
| Tokens/day | 500,000 (varies by model) |
| Cost | Free |
More than enough for a personal finance assistant.
### 10.3 Recommended Models
| Model | Speed | Quality | Best For |
|-------|-------|---------|----------|
| `llama-3.3-70b-versatile` | Fast | High | Default — chat + insights |
| `llama-3.1-8b-instant` | Very fast | Good | Quick queries, daily summaries |
Set in `.env` via `GROQ_MODEL`. Switchable from Settings UI.
### 10.4 Verify Key Works
```bash
curl https://api.groq.com/openai/v1/chat/completions -H "Authorization: Bearer YOUR_GROQ_API_KEY" -H "Content-Type: application/json" -d '{
"model": "llama-3.3-70b-versatile",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 10
}'
```
Should return a JSON response with a greeting.
---
## 11. Scheduled Jobs (systemd timers)
### 11.1 Process Recurring Transactions (Daily 6AM)
```bash
sudo nano /etc/systemd/system/pfm-recurring.service
```
```ini
[Unit]
Description=PFMS — Process Recurring Transactions
After=mysql.service
[Service]
Type=oneshot
User=pfm
WorkingDirectory=/home/pfm/app
Environment="PATH=/home/pfm/app/venv/bin"
EnvironmentFile=/home/pfm/app/.env
ExecStart=/home/pfm/app/venv/bin/python scripts/process_recurring.py
```
```bash
sudo nano /etc/systemd/system/pfm-recurring.timer
```
```ini
[Unit]
Description=PFMS — Recurring Transactions Timer
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
[Install]
WantedBy=timers.target
```
### 11.2 Fetch Investment Prices (Daily 4PM weekdays)
```bash
sudo nano /etc/systemd/system/pfm-prices.service
```
```ini
[Unit]
Description=PFMS — Fetch Investment Prices
[Service]
Type=oneshot
User=pfm
WorkingDirectory=/home/pfm/app
Environment="PATH=/home/pfm/app/venv/bin"
EnvironmentFile=/home/pfm/app/.env
ExecStart=/home/pfm/app/venv/bin/python scripts/fetch_prices.py
```
```bash
sudo nano /etc/systemd/system/pfm-prices.timer
```
```ini
[Unit]
Description=PFMS — Price Fetch Timer
[Timer]
OnCalendar=Mon-Fri *-*-* 16:00:00
Persistent=true
[Install]
WantedBy=timers.target
```
### 11.3 Net Worth Snapshot (1st of month)
```bash
sudo nano /etc/systemd/system/pfm-snapshot.service
```
```ini
[Unit]
Description=PFMS — Net Worth Snapshot
[Service]
Type=oneshot
User=pfm
WorkingDirectory=/home/pfm/app
Environment="PATH=/home/pfm/app/venv/bin"
EnvironmentFile=/home/pfm/app/.env
ExecStart=/home/pfm/app/venv/bin/python scripts/daily_snapshot.py
```
```bash
sudo nano /etc/systemd/system/pfm-snapshot.timer
```
```ini
[Unit]
Description=PFMS — Net Worth Snapshot Timer
[Timer]
OnCalendar=*-*-01 00:05:00
Persistent=true
[Install]
WantedBy=timers.target
```
### 11.4 AI Daily Insight (Midnight)
```bash
sudo nano /etc/systemd/system/pfm-aiinsight.service
```
```ini
[Unit]
Description=PFMS — AI Daily Insight
After=network-online.target
[Service]
Type=oneshot
User=pfm
WorkingDirectory=/home/pfm/app
Environment="PATH=/home/pfm/app/venv/bin"
EnvironmentFile=/home/pfm/app/.env
ExecStart=/home/pfm/app/venv/bin/python scripts/daily_ai_insight.py
```
```bash
sudo nano /etc/systemd/system/pfm-aiinsight.timer
```
```ini
[Unit]
Description=PFMS — AI Insight Timer
[Timer]
OnCalendar=*-*-* 00:01:00
Persistent=true
[Install]
WantedBy=timers.target
```
### 11.5 Fetch USD/VND Rate (Daily 8AM)
```bash
sudo nano /etc/systemd/system/pfm-fxrate.service
```
```ini
[Unit]
Description=PFMS — Fetch USD/VND Exchange Rate
[Service]
Type=oneshot
User=pfm
WorkingDirectory=/home/pfm/app
Environment="PATH=/home/pfm/app/venv/bin"
EnvironmentFile=/home/pfm/app/.env
ExecStart=/home/pfm/app/venv/bin/python scripts/fetch_fx_rate.py
```
```bash
sudo nano /etc/systemd/system/pfm-fxrate.timer
```
```ini
[Unit]
Description=PFMS — FX Rate Fetch Timer
[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true
[Install]
WantedBy=timers.target
```
### Enable All Timers
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now pfm-recurring.timer
sudo systemctl enable --now pfm-fxrate.timer
sudo systemctl enable --now pfm-prices.timer
sudo systemctl enable --now pfm-snapshot.timer
sudo systemctl enable --now pfm-aiinsight.timer
# Verify
sudo systemctl list-timers --all | grep pfm
```
---
## 12. Log Rotation
```bash
sudo nano /etc/logrotate.d/pfm
```
```
/var/log/pfm/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 pfm www-data
postrotate
systemctl reload pfm
endscript
}
```
---
## 13. Database Backup (Daily)
```bash
sudo nano /etc/systemd/system/pfm-backup.service
```
```ini
[Unit]
Description=PFMS — MySQL Backup
[Service]
Type=oneshot
User=pfm
ExecStart=/bin/bash -c 'mysqldump --defaults-file=/home/pfm/.my.cnf pfm_db | gzip > /home/pfm/app/backups/pfm_$(date +\%Y\%m\%d).sql.gz'
```
```bash
sudo nano /etc/systemd/system/pfm-backup.timer
```
```ini
[Unit]
Description=PFMS — Daily Backup Timer
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
```
```bash
sudo mkdir -p /home/pfm/app/backups
sudo chown pfm:pfm /home/pfm/app/backups
sudo systemctl enable --now pfm-backup.timer
```
> Store MySQL credentials securely for the backup job:
> ```bash
> sudo -u pfm bash -c 'cat > /home/pfm/.my.cnf << EOF
> [client]
> user=pfm_user
> password=STRONG_PASSWORD_HERE
> EOF
> chmod 600 /home/pfm/.my.cnf'
> ```
> Keep last 30 days. Add a cleanup cron to delete older files:
> `find /home/pfm/app/backups -name "*.sql.gz" -mtime +30 -delete`
---
## 14. Firewall (UFW)
```bash
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
```
> MySQL (3306) is NOT opened — internal only. Groq calls go outbound over HTTPS (port 443).
---
## 15. Deploy Update Workflow
```bash
# SSH into server
sudo -u pfm bash
cd /home/pfm/app
source venv/bin/activate
git pull origin main
pip install -r requirements.txt # if deps changed
flask db upgrade # if migrations added
sudo systemctl restart pfm
sudo systemctl status pfm
```
---
## 16. Quick Status Check
```bash
# All PFMS services
sudo systemctl status pfm
sudo systemctl status nginx
sudo systemctl status mysql
# Logs (live)
sudo journalctl -u pfm -f
sudo tail -f /var/log/pfm/error.log
sudo tail -f /var/log/nginx/pfm_error.log
# Groq API check
curl https://api.groq.com/openai/v1/models \
-H "Authorization: Bearer $(grep GROQ_API_KEY /home/pfm/app/.env | cut -d= -f2)"
```
---
## 17. Gitea Integration (your self-hosted)
```bash
# On server — set up deploy key
sudo -u pfm ssh-keygen -t ed25519 -f /home/pfm/.ssh/gitea_deploy -N ""
cat /home/pfm/.ssh/gitea_deploy.pub
# Add this public key to Gitea repo → Settings → Deploy Keys
# Clone with deploy key
sudo -u pfm GIT_SSH_COMMAND='ssh -i /home/pfm/.ssh/gitea_deploy' \
git clone git@gitea.yourdomain.com:youruser/pfm.git /home/pfm/app
```
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
First-run initialization script.
Creates the admin user and seeds default categories.
Run once after `flask db upgrade`.
Usage:
cd /home/pfm/app
source venv/bin/activate
python scripts/init_db.py
"""
import sys
import os
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app
from app.extensions import db
from app.models.user import User
from app.models.category import Category
import getpass
app = create_app()
DEFAULT_EXPENSE_CATEGORIES = [
('Housing', 'bi-house', '#6366f1'),
('Food & Dining', 'bi-cup-hot', '#f59e0b'),
('Transport', 'bi-car-front', '#3b82f6'),
('Utilities', 'bi-lightning-charge', '#8b5cf6'),
('Health', 'bi-heart-pulse', '#ef4444'),
('Entertainment', 'bi-controller', '#ec4899'),
('Shopping', 'bi-bag', '#f97316'),
('Education', 'bi-book', '#14b8a6'),
('Insurance', 'bi-shield-check', '#64748b'),
('Personal Care', 'bi-person-heart', '#a78bfa'),
('Travel', 'bi-airplane', '#0ea5e9'),
('Subscriptions', 'bi-repeat', '#84cc16'),
('Gifts', 'bi-gift', '#f43f5e'),
('Other', 'bi-three-dots', '#94a3b8'),
]
DEFAULT_INCOME_CATEGORIES = [
('Salary', 'bi-briefcase', '#10b981'),
('Freelance', 'bi-laptop', '#06b6d4'),
('Business', 'bi-building', '#8b5cf6'),
('Investment', 'bi-graph-up-arrow', '#3b82f6'),
('Rental', 'bi-house-door', '#f59e0b'),
('Gift Received', 'bi-gift', '#ec4899'),
('Other Income', 'bi-three-dots', '#94a3b8'),
]
def seed_categories():
print("Seeding categories...")
count = 0
for name, icon, color in DEFAULT_EXPENSE_CATEGORIES:
if not Category.query.filter_by(name=name, category_type='expense').first():
db.session.add(Category(
name=name, icon=icon, color=color,
category_type='expense', is_system=True
))
count += 1
for name, icon, color in DEFAULT_INCOME_CATEGORIES:
if not Category.query.filter_by(name=name, category_type='income').first():
db.session.add(Category(
name=name, icon=icon, color=color,
category_type='income', is_system=True
))
count += 1
db.session.commit()
print(f"{count} categories added.")
def create_admin():
print("\nCreate admin user")
print("-" * 30)
existing = User.query.first()
if existing:
print(f" User '{existing.username}' already exists. Skipping.")
return
username = input("Username [admin]: ").strip() or 'admin'
display_name = input("Display name (optional): ").strip() or None
email = input("Email (optional): ").strip() or None
while True:
password = getpass.getpass("Password: ")
confirm = getpass.getpass("Confirm password: ")
if password == confirm and len(password) >= 6:
break
if password != confirm:
print(" Passwords do not match. Try again.")
else:
print(" Password must be at least 6 characters.")
from flask import current_app
user = User(
username=username,
display_name=display_name,
email=email,
currency=current_app.config.get('APP_CURRENCY', 'USD'),
currency_symbol=current_app.config.get('APP_CURRENCY_SYMBOL', '$'),
timezone=current_app.config.get('APP_TIMEZONE', 'Asia/Ho_Chi_Minh'),
)
user.set_password(password)
db.session.add(user)
db.session.commit()
print(f" → User '{username}' created successfully.")
if __name__ == '__main__':
with app.app_context():
seed_categories()
create_admin()
print("\nInit complete. Run the app with: gunicorn wsgi:app")
+16
View File
@@ -0,0 +1,16 @@
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
View File
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
First-run initialization script.
Creates the admin user and seeds default categories.
Run once after `flask db upgrade`.
Usage:
cd /home/pfm/app
source venv/bin/activate
python scripts/init_db.py
"""
import sys
import os
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app
from app.extensions import db
from app.models.user import User
from app.models.category import Category
import getpass
app = create_app()
DEFAULT_EXPENSE_CATEGORIES = [
('Housing', 'bi-house', '#6366f1'),
('Food & Dining', 'bi-cup-hot', '#f59e0b'),
('Transport', 'bi-car-front', '#3b82f6'),
('Utilities', 'bi-lightning-charge', '#8b5cf6'),
('Health', 'bi-heart-pulse', '#ef4444'),
('Entertainment', 'bi-controller', '#ec4899'),
('Shopping', 'bi-bag', '#f97316'),
('Education', 'bi-book', '#14b8a6'),
('Insurance', 'bi-shield-check', '#64748b'),
('Personal Care', 'bi-person-heart', '#a78bfa'),
('Travel', 'bi-airplane', '#0ea5e9'),
('Subscriptions', 'bi-repeat', '#84cc16'),
('Gifts', 'bi-gift', '#f43f5e'),
('Other', 'bi-three-dots', '#94a3b8'),
]
DEFAULT_INCOME_CATEGORIES = [
('Salary', 'bi-briefcase', '#10b981'),
('Freelance', 'bi-laptop', '#06b6d4'),
('Business', 'bi-building', '#8b5cf6'),
('Investment', 'bi-graph-up-arrow', '#3b82f6'),
('Rental', 'bi-house-door', '#f59e0b'),
('Gift Received', 'bi-gift', '#ec4899'),
('Other Income', 'bi-three-dots', '#94a3b8'),
]
def seed_categories():
print("Seeding categories...")
count = 0
for name, icon, color in DEFAULT_EXPENSE_CATEGORIES:
if not Category.query.filter_by(name=name, category_type='expense').first():
db.session.add(Category(
name=name, icon=icon, color=color,
category_type='expense', is_system=True
))
count += 1
for name, icon, color in DEFAULT_INCOME_CATEGORIES:
if not Category.query.filter_by(name=name, category_type='income').first():
db.session.add(Category(
name=name, icon=icon, color=color,
category_type='income', is_system=True
))
count += 1
db.session.commit()
print(f"{count} categories added.")
def create_admin():
print("\nCreate admin user")
print("-" * 30)
existing = User.query.first()
if existing:
print(f" User '{existing.username}' already exists. Skipping.")
return
username = input("Username [admin]: ").strip() or 'admin'
display_name = input("Display name (optional): ").strip() or None
email = input("Email (optional): ").strip() or None
while True:
password = getpass.getpass("Password: ")
confirm = getpass.getpass("Confirm password: ")
if password == confirm and len(password) >= 6:
break
if password != confirm:
print(" Passwords do not match. Try again.")
else:
print(" Password must be at least 6 characters.")
from flask import current_app
user = User(
username=username,
display_name=display_name,
email=email,
currency=current_app.config.get('APP_CURRENCY', 'USD'),
currency_symbol=current_app.config.get('APP_CURRENCY_SYMBOL', '$'),
timezone=current_app.config.get('APP_TIMEZONE', 'Asia/Ho_Chi_Minh'),
)
user.set_password(password)
db.session.add(user)
db.session.commit()
print(f" → User '{username}' created successfully.")
if __name__ == '__main__':
with app.app_context():
seed_categories()
create_admin()
print("\nInit complete. Run the app with: gunicorn wsgi:app")
+6
View File
@@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run()