05/31 Phase 1: initial codes
This commit is contained in:
@@ -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
@@ -1,176 +1,42 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
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/
|
||||
dist/
|
||||
build/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# Flask / Migrations (keep structure, ignore compiled)
|
||||
instance/
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
# Uploads
|
||||
uploads/
|
||||
app/static/uploads/
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# 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
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Tests
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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}>'
|
||||
@@ -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))
|
||||
@@ -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'))
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user