diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..54e73fc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 36b13f1..fda3c23 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..763912c --- /dev/null +++ b/CLAUDE.md @@ -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) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..83afea9 --- /dev/null +++ b/__init__.py @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..83afea9 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..dc74c17 --- /dev/null +++ b/app/config.py @@ -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, +} diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..37ab120 --- /dev/null +++ b/app/extensions.py @@ -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' diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..69f3d62 --- /dev/null +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/account.py b/app/models/account.py new file mode 100644 index 0000000..8799f50 --- /dev/null +++ b/app/models/account.py @@ -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'' diff --git a/app/models/ai_insight.py b/app/models/ai_insight.py new file mode 100644 index 0000000..6617b6c --- /dev/null +++ b/app/models/ai_insight.py @@ -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'' diff --git a/app/models/budget.py b/app/models/budget.py new file mode 100644 index 0000000..cb6e82c --- /dev/null +++ b/app/models/budget.py @@ -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'' diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..a52ce94 --- /dev/null +++ b/app/models/category.py @@ -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'' diff --git a/app/models/fx_rate.py b/app/models/fx_rate.py new file mode 100644 index 0000000..1ab63ca --- /dev/null +++ b/app/models/fx_rate.py @@ -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'' diff --git a/app/models/goal.py b/app/models/goal.py new file mode 100644 index 0000000..f28c1a7 --- /dev/null +++ b/app/models/goal.py @@ -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'' + + +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'' diff --git a/app/models/investment.py b/app/models/investment.py new file mode 100644 index 0000000..388551f --- /dev/null +++ b/app/models/investment.py @@ -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'' + + +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'' diff --git a/app/models/net_worth_snapshot.py b/app/models/net_worth_snapshot.py new file mode 100644 index 0000000..fda535e --- /dev/null +++ b/app/models/net_worth_snapshot.py @@ -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'' diff --git a/app/models/receipt.py b/app/models/receipt.py new file mode 100644 index 0000000..cecfd7f --- /dev/null +++ b/app/models/receipt.py @@ -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'' diff --git a/app/models/recurring_rule.py b/app/models/recurring_rule.py new file mode 100644 index 0000000..be96f7a --- /dev/null +++ b/app/models/recurring_rule.py @@ -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'' diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..7f5bb35 --- /dev/null +++ b/app/models/transaction.py @@ -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'' diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..9e13822 --- /dev/null +++ b/app/models/user.py @@ -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'' + + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..5b273d1 --- /dev/null +++ b/app/routes/auth.py @@ -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')) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..344aec6 --- /dev/null +++ b/app/routes/dashboard.py @@ -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') diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..7ade3a1 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,251 @@ + + + + + + Sign In — PFM + + + + + + + + + + + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..b2e8970 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,471 @@ + + + + + + {% block title %}PFM{% endblock %} — Personal Finance + + + + + + + + + + + + + + + + + + + +
+ + {% block page_title %}{% endblock %} +
+ + {{ current_user.display_name or current_user.username }} + +
+
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} + {% endwith %} +
+ + +
+ {% block content %}{% endblock %} +
+ + + + + + +{% block extra_js %}{% endblock %} + + diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html new file mode 100644 index 0000000..6dfdfab --- /dev/null +++ b/app/templates/dashboard/index.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block page_title %}Dashboard{% endblock %} + +{% block content %} +
+
+
+ +
Dashboard
+

Phase 2 will populate this with live data.

+
+
+
+{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 0000000..4123d70 --- /dev/null +++ b/app/utils/decorators.py @@ -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 diff --git a/app/utils/formatters.py b/app/utils/formatters.py new file mode 100644 index 0000000..fb870da --- /dev/null +++ b/app/utils/formatters.py @@ -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}" diff --git a/deploy.md b/deploy.md new file mode 100644 index 0000000..b8868a1 --- /dev/null +++ b/deploy.md @@ -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 +``` diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..efda8c5 --- /dev/null +++ b/init_db.py @@ -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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..db8f293 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..efda8c5 --- /dev/null +++ b/scripts/init_db.py @@ -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") diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..3dc5f39 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run()