05/31 Phase 2

This commit is contained in:
2026-05-31 11:17:53 -04:00
parent 1c34dcbd28
commit 9f2a0acc38
18 changed files with 1817 additions and 391 deletions
+105
View File
@@ -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('/<int:id>/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('/<int:id>/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'))
+119
View File
@@ -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('/<int:id>/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('/<int:id>/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'))
+151 -3
View File
@@ -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],
})
+222
View File
@@ -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('/<int:id>/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('/<int:id>/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)