diff --git a/app/__init__.py b/app/__init__.py index 83afea9..f61a617 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -25,9 +25,15 @@ def create_app(config_name=None): # Register blueprints from app.routes.auth import auth_bp from app.routes.dashboard import dashboard_bp + from app.routes.accounts import accounts_bp + from app.routes.categories import categories_bp + from app.routes.transactions import transactions_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) + app.register_blueprint(accounts_bp) + app.register_blueprint(categories_bp) + app.register_blueprint(transactions_bp) # Import all models so Flask-Migrate can see them with app.app_context(): @@ -38,12 +44,11 @@ def create_app(config_name=None): AiInsight, FxRate ) - # Jinja2 template globals + # Jinja2 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) diff --git a/app/routes/accounts.py b/app/routes/accounts.py new file mode 100644 index 0000000..49da1ba --- /dev/null +++ b/app/routes/accounts.py @@ -0,0 +1,105 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import login_required +from flask_wtf import FlaskForm +from wtforms import StringField, SelectField, TextAreaField, SubmitField +from wtforms.validators import DataRequired, Length, Optional +from app.extensions import db +from app.models.account import Account +from app.services.account_service import calc_balance + +accounts_bp = Blueprint('accounts', __name__, url_prefix='/accounts') + +ACCOUNT_TYPES = [ + ('checking', 'Checking'), + ('savings', 'Savings'), + ('cash', 'Cash'), + ('credit_card', 'Credit Card'), + ('crypto', 'Crypto Wallet'), + ('investment', 'Investment'), + ('other', 'Other'), +] + +ACCOUNT_ICONS = [ + ('bi-bank', 'Bank'), + ('bi-wallet2', 'Wallet'), + ('bi-cash-stack', 'Cash'), + ('bi-credit-card', 'Credit Card'), + ('bi-currency-bitcoin', 'Crypto'), + ('bi-graph-up', 'Investment'), + ('bi-safe', 'Safe'), +] + +ACCOUNT_COLORS = [ + '#4F81C7', '#10B981', '#F59E0B', '#EF4444', + '#8B5CF6', '#EC4899', '#06B6D4', '#64748B', +] + + +class AccountForm(FlaskForm): + name = StringField('Account Name', validators=[DataRequired(), Length(1, 100)]) + account_type = SelectField('Type', choices=ACCOUNT_TYPES, validators=[DataRequired()]) + color = StringField('Color', default='#4F81C7') + icon = StringField('Icon', default='bi-bank') + notes = TextAreaField('Notes', validators=[Optional(), Length(max=500)]) + submit = SubmitField('Save') + + +@accounts_bp.route('/') +@login_required +def index(): + accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all() + # Recalc balances on page load + for a in accounts: + calc_balance(a.id) + accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all() + return render_template('accounts/index.html', accounts=accounts) + + +@accounts_bp.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + form = AccountForm() + if form.validate_on_submit(): + account = Account( + name=form.name.data.strip(), + account_type=form.account_type.data, + color=form.color.data or '#4F81C7', + icon=form.icon.data or 'bi-bank', + notes=form.notes.data, + balance=0, + ) + db.session.add(account) + db.session.commit() + flash(f'Account "{account.name}" created.', 'success') + return redirect(url_for('accounts.index')) + return render_template('accounts/form.html', form=form, title='New Account', + colors=ACCOUNT_COLORS, icons=ACCOUNT_ICONS) + + +@accounts_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(id): + account = db.get_or_404(Account, id) + form = AccountForm(obj=account) + if form.validate_on_submit(): + account.name = form.name.data.strip() + account.account_type = form.account_type.data + account.color = form.color.data or account.color + account.icon = form.icon.data or account.icon + account.notes = form.notes.data + db.session.commit() + flash(f'Account "{account.name}" updated.', 'success') + return redirect(url_for('accounts.index')) + return render_template('accounts/form.html', form=form, title='Edit Account', + account=account, colors=ACCOUNT_COLORS, icons=ACCOUNT_ICONS) + + +@accounts_bp.route('//delete', methods=['POST']) +@login_required +def delete(id): + account = db.get_or_404(Account, id) + # Soft delete + account.is_active = False + db.session.commit() + flash(f'Account "{account.name}" removed.', 'info') + return redirect(url_for('accounts.index')) diff --git a/app/routes/categories.py b/app/routes/categories.py new file mode 100644 index 0000000..4649d59 --- /dev/null +++ b/app/routes/categories.py @@ -0,0 +1,119 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_required +from flask_wtf import FlaskForm +from wtforms import StringField, SelectField, SubmitField +from wtforms.validators import DataRequired, Length +from app.extensions import db +from app.models.category import Category + +categories_bp = Blueprint('categories', __name__, url_prefix='/categories') + +CATEGORY_TYPES = [ + ('expense', 'Expense'), + ('income', 'Income'), + ('both', 'Both'), +] + +CATEGORY_COLORS = [ + '#6366f1', '#f59e0b', '#3b82f6', '#8b5cf6', '#ef4444', + '#ec4899', '#f97316', '#14b8a6', '#64748b', '#10b981', + '#0ea5e9', '#84cc16', '#a78bfa', '#94a3b8', '#f43f5e', +] + +CATEGORY_ICONS = [ + 'bi-house', 'bi-cup-hot', 'bi-car-front', 'bi-lightning-charge', + 'bi-heart-pulse', 'bi-controller', 'bi-bag', 'bi-book', + 'bi-shield-check', 'bi-person-heart', 'bi-airplane', 'bi-repeat', + 'bi-gift', 'bi-three-dots', 'bi-briefcase', 'bi-laptop', + 'bi-building', 'bi-graph-up-arrow', 'bi-house-door', 'bi-tag', + 'bi-cart', 'bi-music-note', 'bi-phone', 'bi-tools', +] + + +class CategoryForm(FlaskForm): + name = StringField('Name', validators=[DataRequired(), Length(1, 100)]) + category_type = SelectField('Type', choices=CATEGORY_TYPES, validators=[DataRequired()]) + color = StringField('Color', default='#6B7280') + icon = StringField('Icon', default='bi-tag') + submit = SubmitField('Save') + + +@categories_bp.route('/') +@login_required +def index(): + expense_cats = Category.query.filter( + Category.category_type.in_(['expense', 'both']), + Category.is_active == True, + Category.parent_id == None + ).order_by(Category.is_system.desc(), Category.name).all() + + income_cats = Category.query.filter( + Category.category_type.in_(['income', 'both']), + Category.is_active == True, + Category.parent_id == None + ).order_by(Category.is_system.desc(), Category.name).all() + + return render_template('categories/index.html', + expense_cats=expense_cats, + income_cats=income_cats) + + +@categories_bp.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + form = CategoryForm() + # Pre-select type from query param + if request.method == 'GET' and request.args.get('type'): + form.category_type.data = request.args.get('type') + + if form.validate_on_submit(): + cat = Category( + name=form.name.data.strip(), + category_type=form.category_type.data, + color=form.color.data or '#6B7280', + icon=form.icon.data or 'bi-tag', + is_system=False, + ) + db.session.add(cat) + db.session.commit() + flash(f'Category "{cat.name}" created.', 'success') + return redirect(url_for('categories.index')) + return render_template('categories/form.html', form=form, title='New Category', + colors=CATEGORY_COLORS, icons=CATEGORY_ICONS) + + +@categories_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(id): + cat = db.get_or_404(Category, id) + form = CategoryForm(obj=cat) + if form.validate_on_submit(): + if cat.is_system and cat.name != form.name.data.strip(): + flash('System category names cannot be changed.', 'warning') + else: + cat.name = form.name.data.strip() + cat.category_type = form.category_type.data + cat.color = form.color.data or cat.color + cat.icon = form.icon.data or cat.icon + db.session.commit() + flash(f'Category "{cat.name}" updated.', 'success') + return redirect(url_for('categories.index')) + return render_template('categories/form.html', form=form, title='Edit Category', + category=cat, colors=CATEGORY_COLORS, icons=CATEGORY_ICONS) + + +@categories_bp.route('//delete', methods=['POST']) +@login_required +def delete(id): + cat = db.get_or_404(Category, id) + if cat.is_system: + flash('System categories cannot be deleted.', 'warning') + return redirect(url_for('categories.index')) + # Check if in use + if cat.transactions.count() > 0: + flash('Cannot delete category with existing transactions. Deactivate instead.', 'warning') + return redirect(url_for('categories.index')) + cat.is_active = False + db.session.commit() + flash(f'Category "{cat.name}" removed.', 'info') + return redirect(url_for('categories.index')) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 344aec6..4e779e7 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,10 +1,158 @@ -from flask import Blueprint, render_template -from flask_login import login_required +from flask import Blueprint, render_template, request, jsonify +from flask_login import login_required, current_user +from sqlalchemy import func, extract +from app.extensions import db +from app.models.account import Account +from app.models.transaction import Transaction +from app.models.category import Category +from app.services.fx_service import get_today_rate, get_rate_history +from app.services.account_service import get_total_assets, get_total_liabilities +from datetime import date, datetime, timedelta +import calendar dashboard_bp = Blueprint('dashboard', __name__) +def _parse_date_range(period): + """Return (date_from, date_to, label) for a given period string.""" + today = date.today() + if period == 'last_month': + first = (today.replace(day=1) - timedelta(days=1)).replace(day=1) + last = today.replace(day=1) - timedelta(days=1) + label = first.strftime('%B %Y') + elif period == 'custom': + try: + date_from = datetime.strptime(request.args.get('date_from', ''), '%Y-%m-%d').date() + date_to = datetime.strptime(request.args.get('date_to', ''), '%Y-%m-%d').date() + except ValueError: + date_from = today.replace(day=1) + date_to = today + return date_from, date_to, 'Custom Range' + else: # this_month (default) + first = today.replace(day=1) + last = today + label = first.strftime('%B %Y') + return first, last, label + + @dashboard_bp.route('/') @login_required def index(): - return render_template('dashboard/index.html') + period = request.args.get('period', 'this_month') + date_from, date_to, period_label = _parse_date_range(period) + + # ── Summary cards ──────────────────────────────── + total_income = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.transaction_type == 'income', + Transaction.date >= date_from, + Transaction.date <= date_to, + ).scalar() + + total_expense = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.transaction_type == 'expense', + Transaction.date >= date_from, + Transaction.date <= date_to, + ).scalar() + + net_cash_flow = float(total_income) - float(total_expense) + + # ── Accounts ───────────────────────────────────── + accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all() + total_assets = get_total_assets() + total_liabilities = get_total_liabilities() + net_worth = total_assets - total_liabilities + + # ── Top expense categories ──────────────────────── + top_categories = db.session.query( + Category.name, + Category.color, + Category.icon, + func.sum(Transaction.amount).label('total') + ).join(Transaction, Transaction.category_id == Category.id)\ + .filter( + Transaction.transaction_type == 'expense', + Transaction.date >= date_from, + Transaction.date <= date_to, + ).group_by(Category.id)\ + .order_by(func.sum(Transaction.amount).desc())\ + .limit(5).all() + + # ── Cash flow chart (last 6 months) ────────────── + chart_months = [] + chart_income = [] + chart_expense = [] + today = date.today() + for i in range(5, -1, -1): + # go back i months from current + month_date = (today.replace(day=1) - timedelta(days=i * 28)).replace(day=1) + last_day = calendar.monthrange(month_date.year, month_date.month)[1] + m_start = month_date + m_end = month_date.replace(day=last_day) + + inc = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.transaction_type == 'income', + Transaction.date >= m_start, + Transaction.date <= m_end, + ).scalar() + + exp = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.transaction_type == 'expense', + Transaction.date >= m_start, + Transaction.date <= m_end, + ).scalar() + + chart_months.append(month_date.strftime('%b %Y')) + chart_income.append(float(inc)) + chart_expense.append(float(exp)) + + # ── Recent transactions ─────────────────────────── + recent_txns = Transaction.query\ + .filter(Transaction.transaction_type.in_(['income', 'expense']))\ + .order_by(Transaction.date.desc(), Transaction.id.desc())\ + .limit(8).all() + + # ── USD/VND rate ────────────────────────────────── + fx = get_today_rate() + fx_history = get_rate_history(30) + fx_history_data = { + 'dates': [r.date.strftime('%b %d') for r in fx_history], + 'rates': [float(r.usd_to_vnd) for r in fx_history], + } + + return render_template('dashboard/index.html', + period=period, + period_label=period_label, + date_from=date_from, + date_to=date_to, + total_income=float(total_income), + total_expense=float(total_expense), + net_cash_flow=net_cash_flow, + accounts=accounts, + total_assets=total_assets, + total_liabilities=total_liabilities, + net_worth=net_worth, + top_categories=top_categories, + chart_months=chart_months, + chart_income=chart_income, + chart_expense=chart_expense, + recent_txns=recent_txns, + fx=fx, + fx_history_data=fx_history_data) + + +@dashboard_bp.route('/api/fx-history') +@login_required +def fx_history_api(): + history = get_rate_history(30) + return jsonify({ + 'dates': [r.date.strftime('%b %d') for r in history], + 'rates': [float(r.usd_to_vnd) for r in history], + }) diff --git a/app/routes/transactions.py b/app/routes/transactions.py new file mode 100644 index 0000000..38de569 --- /dev/null +++ b/app/routes/transactions.py @@ -0,0 +1,222 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import login_required +from flask_wtf import FlaskForm +from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField +from wtforms.validators import DataRequired, Optional, NumberRange +from app.extensions import db +from app.models.transaction import Transaction +from app.models.account import Account +from app.models.category import Category +from app.services.account_service import calc_balance +from datetime import date, datetime +from sqlalchemy import or_ + +transactions_bp = Blueprint('transactions', __name__, url_prefix='/transactions') + + +def _account_choices(): + return [(str(a.id), a.name) + for a in Account.query.filter_by(is_active=True).order_by(Account.name).all()] + + +def _category_choices(cat_type): + cats = Category.query.filter( + Category.category_type.in_([cat_type, 'both']), + Category.is_active == True, + Category.parent_id == None + ).order_by(Category.name).all() + return [('', '— None —')] + [(str(c.id), c.name) for c in cats] + + +class TransactionForm(FlaskForm): + transaction_type = HiddenField(default='expense') + account_id = SelectField('Account', validators=[DataRequired()]) + category_id = SelectField('Category', validators=[Optional()]) + amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)], + places=2) + description = StringField('Description', validators=[DataRequired()]) + date = DateField('Date', validators=[DataRequired()], default=date.today) + notes = TextAreaField('Notes', validators=[Optional()]) + submit = SubmitField('Save') + + +class TransferForm(FlaskForm): + from_account_id = SelectField('From Account', validators=[DataRequired()]) + to_account_id = SelectField('To Account', validators=[DataRequired()]) + amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)], + places=2) + description = StringField('Description', default='Transfer') + date = DateField('Date', validators=[DataRequired()], default=date.today) + notes = TextAreaField('Notes', validators=[Optional()]) + submit = SubmitField('Transfer') + + +@transactions_bp.route('/') +@login_required +def index(): + tab = request.args.get('tab', 'expense') # 'income' | 'expense' + page = request.args.get('page', 1, type=int) + search = request.args.get('q', '').strip() + category_id = request.args.get('category_id', '', type=str) + account_id = request.args.get('account_id', '', type=str) + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + + query = Transaction.query.filter( + Transaction.transaction_type == tab + ).order_by(Transaction.date.desc(), Transaction.id.desc()) + + if search: + query = query.filter(Transaction.description.ilike(f'%{search}%')) + if category_id: + query = query.filter(Transaction.category_id == int(category_id)) + if account_id: + query = query.filter(Transaction.account_id == int(account_id)) + if date_from: + try: + query = query.filter(Transaction.date >= datetime.strptime(date_from, '%Y-%m-%d').date()) + except ValueError: + pass + if date_to: + try: + query = query.filter(Transaction.date <= datetime.strptime(date_to, '%Y-%m-%d').date()) + except ValueError: + pass + + pagination = query.paginate(page=page, per_page=30, error_out=False) + + # Counts for tabs + income_count = Transaction.query.filter_by(transaction_type='income').count() + expense_count = Transaction.query.filter_by(transaction_type='expense').count() + + accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all() + categories = Category.query.filter( + Category.category_type.in_([tab, 'both']), + Category.is_active == True + ).order_by(Category.name).all() + + return render_template('transactions/index.html', + pagination=pagination, + transactions=pagination.items, + tab=tab, + income_count=income_count, + expense_count=expense_count, + accounts=accounts, + categories=categories, + search=search, + category_id=category_id, + account_id=account_id, + date_from=date_from, + date_to=date_to) + + +@transactions_bp.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + txn_type = request.args.get('type', 'expense') + if txn_type not in ('income', 'expense'): + txn_type = 'expense' + + form = TransactionForm() + form.transaction_type.data = txn_type + form.account_id.choices = _account_choices() + form.category_id.choices = _category_choices(txn_type) + + if form.validate_on_submit(): + txn = Transaction( + transaction_type=form.transaction_type.data, + account_id=int(form.account_id.data), + category_id=int(form.category_id.data) if form.category_id.data else None, + amount=form.amount.data, + description=form.description.data.strip(), + date=form.date.data, + notes=form.notes.data, + ) + db.session.add(txn) + db.session.commit() + calc_balance(txn.account_id) + flash(f'{"Income" if txn_type == "income" else "Expense"} added.', 'success') + return redirect(url_for('transactions.index', tab=txn_type)) + + return render_template('transactions/form.html', + form=form, + txn_type=txn_type, + title=f'New {"Income" if txn_type == "income" else "Expense"}') + + +@transactions_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(id): + txn = db.get_or_404(Transaction, id) + form = TransactionForm(obj=txn) + form.transaction_type.data = txn.transaction_type + form.account_id.choices = _account_choices() + form.category_id.choices = _category_choices(txn.transaction_type) + + # Pre-populate foreign keys as strings for SelectField + if request.method == 'GET': + form.account_id.data = str(txn.account_id) + form.category_id.data = str(txn.category_id) if txn.category_id else '' + + if form.validate_on_submit(): + old_account_id = txn.account_id + txn.account_id = int(form.account_id.data) + txn.category_id = int(form.category_id.data) if form.category_id.data else None + txn.amount = form.amount.data + txn.description = form.description.data.strip() + txn.date = form.date.data + txn.notes = form.notes.data + db.session.commit() + calc_balance(old_account_id) + calc_balance(txn.account_id) + flash('Transaction updated.', 'success') + return redirect(url_for('transactions.index', tab=txn.transaction_type)) + + return render_template('transactions/form.html', + form=form, + txn=txn, + txn_type=txn.transaction_type, + title='Edit Transaction') + + +@transactions_bp.route('//delete', methods=['POST']) +@login_required +def delete(id): + txn = db.get_or_404(Transaction, id) + account_id = txn.account_id + txn_type = txn.transaction_type + db.session.delete(txn) + db.session.commit() + calc_balance(account_id) + flash('Transaction deleted.', 'info') + return redirect(url_for('transactions.index', tab=txn_type)) + + +@transactions_bp.route('/transfer', methods=['GET', 'POST']) +@login_required +def transfer(): + form = TransferForm() + form.from_account_id.choices = _account_choices() + form.to_account_id.choices = _account_choices() + + if form.validate_on_submit(): + if form.from_account_id.data == form.to_account_id.data: + flash('Source and destination accounts must be different.', 'warning') + else: + txn = Transaction( + transaction_type='transfer', + account_id=int(form.from_account_id.data), + to_account_id=int(form.to_account_id.data), + amount=form.amount.data, + description=form.description.data.strip() or 'Transfer', + date=form.date.data, + notes=form.notes.data, + ) + db.session.add(txn) + db.session.commit() + calc_balance(txn.account_id) + calc_balance(txn.to_account_id) + flash('Transfer recorded.', 'success') + return redirect(url_for('transactions.index')) + + return render_template('transactions/transfer.html', form=form) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/account_service.py b/app/services/account_service.py new file mode 100644 index 0000000..4c13387 --- /dev/null +++ b/app/services/account_service.py @@ -0,0 +1,85 @@ +""" +Account Service — balance calculated from transactions. +Balance = sum of income - sum of expenses for an account, +plus any incoming transfers minus outgoing transfers. +""" + +from decimal import Decimal +from sqlalchemy import func +from app.extensions import db +from app.models.account import Account +from app.models.transaction import Transaction + + +def calc_balance(account_id): + """Recalculate and persist the balance for a given account.""" + # Income credited to this account + income = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.account_id == account_id, + Transaction.transaction_type == 'income' + ).scalar() + + # Expenses debited from this account + expense = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.account_id == account_id, + Transaction.transaction_type == 'expense' + ).scalar() + + # Transfers out (this account is source) + transfer_out = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.account_id == account_id, + Transaction.transaction_type == 'transfer' + ).scalar() + + # Transfers in (this account is destination) + transfer_in = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.to_account_id == account_id, + Transaction.transaction_type == 'transfer' + ).scalar() + + balance = Decimal(str(income)) - Decimal(str(expense)) \ + - Decimal(str(transfer_out)) + Decimal(str(transfer_in)) + + account = db.session.get(Account, account_id) + if account: + account.balance = balance + db.session.commit() + return balance + + +def recalc_all(): + """Recalculate balances for all accounts.""" + for account in Account.query.filter_by(is_active=True).all(): + calc_balance(account.id) + + +def get_total_assets(): + """Sum of all positive-balance accounts (non-credit).""" + result = db.session.query( + func.coalesce(func.sum(Account.balance), 0) + ).filter( + Account.is_active == True, + Account.account_type != 'credit_card', + Account.balance > 0 + ).scalar() + return float(result) + + +def get_total_liabilities(): + """Sum of credit card balances (negative = owed).""" + result = db.session.query( + func.coalesce(func.sum(Account.balance), 0) + ).filter( + Account.is_active == True, + Account.account_type == 'credit_card', + Account.balance < 0 + ).scalar() + return abs(float(result)) diff --git a/app/services/fx_service.py b/app/services/fx_service.py new file mode 100644 index 0000000..d16d2cd --- /dev/null +++ b/app/services/fx_service.py @@ -0,0 +1,86 @@ +""" +FX Service — fetches and caches daily USD→VND exchange rate. +Primary source: open.er-api.com (free, no key) +Fallback: last known rate from DB +""" + +import requests +from datetime import date, datetime +from app.extensions import db +from app.models.fx_rate import FxRate + + +ER_API_URL = 'https://open.er-api.com/v6/latest/USD' +REQUEST_TIMEOUT = 8 # seconds + + +def get_today_rate(): + """ + Return today's USD→VND rate as a dict: + { 'rate': 25450.00, 'date': date(...), 'source': '...', 'is_stale': False } + """ + today = date.today() + + # 1. Check cache + cached = FxRate.query.filter_by(date=today).first() + if cached: + return { + 'rate': float(cached.usd_to_vnd), + 'date': cached.date, + 'source': cached.source, + 'is_stale': False, + } + + # 2. Fetch from API + rate, source = _fetch_from_api() + + if rate: + record = FxRate( + date=today, + usd_to_vnd=rate, + source=source, + fetched_at=datetime.utcnow(), + ) + db.session.add(record) + try: + db.session.commit() + except Exception: + db.session.rollback() + return {'rate': rate, 'date': today, 'source': source, 'is_stale': False} + + # 3. Fallback — last known rate + last = FxRate.query.order_by(FxRate.date.desc()).first() + if last: + return { + 'rate': float(last.usd_to_vnd), + 'date': last.date, + 'source': last.source, + 'is_stale': True, + } + + # 4. Nothing available + return None + + +def _fetch_from_api(): + """Try open.er-api.com. Returns (rate, source) or (None, None).""" + try: + resp = requests.get(ER_API_URL, timeout=REQUEST_TIMEOUT) + if resp.status_code == 200: + data = resp.json() + vnd = data.get('rates', {}).get('VND') + if vnd: + return float(vnd), 'exchangerate-api' + except Exception: + pass + return None, None + + +def get_rate_history(days=30): + """Return list of FxRate records for last N days, oldest first.""" + from datetime import timedelta + since = date.today() - timedelta(days=days) + return (FxRate.query + .filter(FxRate.date >= since) + .order_by(FxRate.date.asc()) + .all()) diff --git a/app/templates/accounts/form.html b/app/templates/accounts/form.html new file mode 100644 index 0000000..cb1ee66 --- /dev/null +++ b/app/templates/accounts/form.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="e.g. Main Checking") }} + {% for e in form.name.errors %}
{{ e }}
{% endfor %} +
+ +
+ {{ form.account_type.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.account_type(class="form-select" + (" is-invalid" if form.account_type.errors else "")) }} +
+ +
+ +
+ {% for c in colors %} + + {% endfor %} +
+ {{ form.color(type="hidden", id="colorInput") }} +
+ +
+ +
+ {% for icon_val, icon_label in icons %} + + {% endfor %} +
+ {{ form.icon(type="hidden", id="iconInput") }} +
+ +
+ {{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/accounts/index.html b/app/templates/accounts/index.html new file mode 100644 index 0000000..7681be6 --- /dev/null +++ b/app/templates/accounts/index.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Accounts{% endblock %} +{% block page_title %}Accounts{% endblock %} + +{% block topbar_actions %} +New Account +{% endblock %} + +{% block content %} +{% if accounts %} +
+ {% for acct in accounts %} +
+
+
+
+
+ +
+
+
{{ acct.name }}
+
{{ acct.account_type | replace('_',' ') | title }}
+
+
+ +
+
+ {{ acct.balance | currency }} +
+ {% if acct.notes %} +
{{ acct.notes }}
+ {% endif %} +
+
+ {% endfor %} +
+{% else %} +
+ +
No accounts yet
+

Add your bank accounts, cash, and credit cards.

+ Add Account +
+{% endif %} +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index b2e8970..8ceda11 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,471 +1,269 @@ - + {% block title %}PFM{% endblock %} — Personal Finance - - - - - - + - - +
- + - +
- - {% block page_title %}{% endblock %} -
- - {{ current_user.display_name or current_user.username }} - + + {% block page_title %}{% endblock %} +
+ {% block topbar_actions %}{% endblock %} + {{ current_user.display_name or current_user.username }}
- -
+ +
{% with messages = get_flashed_messages(with_categories=true) %} - {% for category, message in messages %} - - -
- {% block content %}{% endblock %} -
+ +
{% block content %}{% endblock %}
- - - {% block extra_js %}{% endblock %} diff --git a/app/templates/categories/form.html b/app/templates/categories/form.html new file mode 100644 index 0000000..acb3551 --- /dev/null +++ b/app/templates/categories/form.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="Category name") }} + {% for e in form.name.errors %}
{{ e }}
{% endfor %} +
+ +
+ {{ form.category_type.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.category_type(class="form-select") }} +
+ +
+ +
+ {% for c in colors %} + + {% endfor %} +
+ {{ form.color(type="hidden", id="colorInput") }} +
+ +
+ +
+ {% for icon_val in icons %} + + {% endfor %} +
+ {{ form.icon(type="hidden", id="iconInput") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/categories/index.html b/app/templates/categories/index.html new file mode 100644 index 0000000..44d21bc --- /dev/null +++ b/app/templates/categories/index.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Categories{% endblock %} +{% block page_title %}Categories{% endblock %} + +{% block topbar_actions %} +Expense +Income +{% endblock %} + +{% block content %} +
+
+
+
Expense Categories
+ {% if expense_cats %} + + + + {% for cat in expense_cats %} + + + + + + {% endfor %} + +
CategoryTypeActions
+
+
+ +
+ {{ cat.name }} + {% if cat.is_system %}system{% endif %} +
+
{{ cat.category_type | title }} + Edit + {% if not cat.is_system %} +
+ + +
+ {% endif %} +
+ {% else %} +

No expense categories.

+ {% endif %} +
+
+
+
+
Income Categories
+ {% if income_cats %} + + + + {% for cat in income_cats %} + + + + + + {% endfor %} + +
CategoryTypeActions
+
+
+ +
+ {{ cat.name }} + {% if cat.is_system %}system{% endif %} +
+
{{ cat.category_type | title }} + Edit + {% if not cat.is_system %} +
+ + +
+ {% endif %} +
+ {% else %} +

No income categories.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html index 6dfdfab..de846eb 100644 --- a/app/templates/dashboard/index.html +++ b/app/templates/dashboard/index.html @@ -2,14 +2,275 @@ {% block title %}Dashboard{% endblock %} {% block page_title %}Dashboard{% endblock %} +{% block extra_css %} +.fx-card { background: #0f172a; border-color: #1e293b; color: #f1f5f9; cursor: pointer; transition: all .2s; } +.fx-card:hover { border-color: #3b82f6 !important; } +.period-btn.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } +{% endblock %} + +{% block topbar_actions %} +
+ This Month + Last Month + +
+{% endblock %} + {% block content %} +
+
+
{{ period_label }}
+ {{ date_from.strftime('%b %d') }} – {{ date_to.strftime('%b %d, %Y') }} +
+
+ Income + Expense +
+
+ + +
+
+
+
+
+
Income
+
{{ total_income | currency }}
+
+
+
+
+
+
+
+
+
+
Expenses
+
{{ total_expense | currency }}
+
+
+
+
+
+
+
+
+
+
Net Cash Flow
+
{{ net_cash_flow | currency }}
+
+
+
+
+
+
+
+
+
+
Net Worth
+
{{ net_worth | currency }}
+
+
+
+
+
+
+ + +
+
+
+
+ Cash Flow — Last 6 Months +
+ Income + Expenses +
+
+
+
+
+
+ + {% if fx %} +
+
+
+
USD → VND
+
₫{{ "{:,.0f}".format(fx.rate) }}
+
+ 1 USD · {{ fx.date.strftime('%b %d, %Y') }} + {% if fx.is_stale %}⚠️{% endif %} +
+
+
+
+ +
+ {% else %} +
+
USD → VND
+
Rate unavailable
+
+ {% endif %} + + +
+
Top Spending
+ {% if top_categories %} + {% set max_val = top_categories[0].total %} + {% for cat in top_categories %} +
+
+ {{ cat.name }} + {{ cat.total | currency }} +
+
+
+
+
+ {% endfor %} + {% else %} +

No expenses this period.

+ {% endif %} +
+
+
+ +
-
-
- -
Dashboard
-

Phase 2 will populate this with live data.

+
+
+
+ Accounts + +
+ {% if accounts %} + {% for acct in accounts %} +
+
+
+ +
+
+
{{ acct.name }}
+
{{ acct.account_type | replace('_',' ') | title }}
+
+
+ {{ acct.balance | currency }} +
+ {% endfor %} +
+
Assets{{ total_assets | currency }}
+
Liabilities{{ total_liabilities | currency }}
+
+ {% else %} +

No accounts. Add one.

+ {% endif %} +
+
+
+
+
+ Recent Transactions + View all +
+ {% if recent_txns %} + + + + + + {% for txn in recent_txns %} + + + + + + + {% endfor %} + +
DateDescriptionCategoryAmount
{{ txn.date.strftime('%b %d') }} +
{{ txn.description }}
+
{{ txn.account.name if txn.account else '—' }}
+
+ {% if txn.category %} + {{ txn.category.name }} + {% else %}{% endif %} + + {% if txn.transaction_type=='income' %}+{% else %}-{% endif %}{{ txn.amount | currency }} +
+ {% else %} +

No transactions yet. Add one.

+ {% endif %} +
+
+
+ + + {% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/app/templates/transactions/form.html b/app/templates/transactions/form.html new file mode 100644 index 0000000..adf7c35 --- /dev/null +++ b/app/templates/transactions/form.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + {{ form.transaction_type() }} + +
+ {{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), placeholder="What was this for?") }} + {% for e in form.description.errors %}
{{ e }}
{% endfor %} +
+ +
+
+ {{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""), placeholder="0.00") }} +
+ {% for e in form.amount.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.date(class="form-control" + (" is-invalid" if form.date.errors else "")) }} +
+
+ +
+ {{ form.account_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.account_id(class="form-select" + (" is-invalid" if form.account_id.errors else "")) }} + {% for e in form.account_id.errors %}
{{ e }}
{% endfor %} +
+ +
+ {{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.category_id(class="form-select") }} +
+ +
+ {{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }} +
+ +
+ + Cancel +
+
+
+
+
+{% endblock %} diff --git a/app/templates/transactions/index.html b/app/templates/transactions/index.html new file mode 100644 index 0000000..f28d179 --- /dev/null +++ b/app/templates/transactions/index.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} +{% block title %}Transactions{% endblock %} +{% block page_title %}Transactions{% endblock %} + +{% block topbar_actions %} +Income +Expense +Transfer +{% endblock %} + +{% block content %} + + + + +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+ + +
+ {% if transactions %} + + + + + + + + + + + + + {% for txn in transactions %} + + + + + + + + + {% endfor %} + +
DateDescriptionCategoryAccountAmountActions
{{ txn.date.strftime('%b %d, %Y') }} +
{{ txn.description }}
+ {% if txn.notes %}
{{ txn.notes | truncate(60) }}
{% endif %} +
+ {% if txn.category %} + + {{ txn.category.name }} + + {% else %}{% endif %} + {{ txn.account.name if txn.account else '—' }} + {% if txn.transaction_type=='income' %}+{% else %}-{% endif %}{{ txn.amount | currency }} + + Edit +
+ + +
+
+ + + {% if pagination.pages > 1 %} +
+ Showing {{ ((pagination.page-1)*30)+1 }}–{{ [pagination.page*30, pagination.total]|min }} of {{ pagination.total }} +
+ {% if pagination.has_prev %} + ← Prev + {% endif %} + {% if pagination.has_next %} + Next → + {% endif %} +
+
+ {% endif %} + + {% else %} +
+ +

No {{ tab }} transactions found.

+ Add {{ tab | title }} +
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/transactions/transfer.html b/app/templates/transactions/transfer.html new file mode 100644 index 0000000..ec24aca --- /dev/null +++ b/app/templates/transactions/transfer.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Transfer{% endblock %} +{% block page_title %}Transfer Between Accounts{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.from_account_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.from_account_id(class="form-select") }} +
+ +
+ +
+ +
+ {{ form.to_account_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.to_account_id(class="form-select") }} +
+ +
+
+ {{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.amount(class="form-control", placeholder="0.00") }} +
+
+
+ {{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.date(class="form-control") }} +
+
+ +
+ {{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.description(class="form-control", placeholder="Transfer description") }} +
+ +
+ {{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.notes(class="form-control", rows=2) }} +
+ +
+ + Cancel +
+
+
+
+
+{% endblock %} diff --git a/scripts/fetch_fx_rate.py b/scripts/fetch_fx_rate.py new file mode 100644 index 0000000..ee19d9c --- /dev/null +++ b/scripts/fetch_fx_rate.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +""" +Cron script: fetch daily USD/VND exchange rate. +Run by systemd timer pfm-fxrate.timer at 8AM daily. +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app import create_app +from app.services.fx_service import get_today_rate + +app = create_app() + +if __name__ == '__main__': + with app.app_context(): + result = get_today_rate() + if result: + status = '(stale)' if result.get('is_stale') else '' + print(f"[fx_rate] 1 USD = {result['rate']:,.0f} VND " + f"[{result['source']}] {result['date']} {status}") + else: + print("[fx_rate] Failed to fetch rate.") + sys.exit(1)