Files
Personal-Finance-Management/app/routes/dashboard.py
T
2026-06-05 15:23:23 -04:00

303 lines
11 KiB
Python

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, force_refresh
from app.services.ai_service import get_latest_daily_insight
from app.services.account_service import get_total_assets, get_total_liabilities
from datetime import date, datetime, timedelta
import calendar
import logging
log = logging.getLogger('app.dashboard')
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():
period = request.args.get('period', 'this_month')
date_from, date_to, period_label = _parse_date_range(period)
# ── Schwab token expiry check ─────────────────────
from app.models.schwab_connection import SchwabConnection
from datetime import timedelta
schwab_warning = None
schwab_conn = SchwabConnection.query.filter_by(is_active=True).first()
if schwab_conn and schwab_conn.refresh_token_expires_at:
days_left = (schwab_conn.refresh_token_expires_at - datetime.utcnow()).days
if days_left <= 2:
schwab_warning = (
f'Schwab connection expires in {max(days_left, 0)} day(s). '
f'Reconnect now to keep syncing.'
)
# ── 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)
savings_rate = round(net_cash_flow / float(total_income) * 100, 1) if total_income else 0
# ── 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
total_cash = float(db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type.in_(['checking', 'savings', 'cash']),
).scalar())
total_investments = float(db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type.in_(['investment', 'crypto']),
).scalar())
# ── 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],
}
# ── AI insight ──────────────────────────────────────
ai_insight = get_latest_daily_insight()
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,
savings_rate=savings_rate,
accounts=accounts,
total_assets=total_assets,
total_liabilities=total_liabilities,
net_worth=net_worth,
total_cash=total_cash,
total_investments=total_investments,
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,
ai_insight=ai_insight,
schwab_warning=schwab_warning)
@dashboard_bp.route('/api/reconcile')
@login_required
def reconcile_api():
"""
Return income/expense totals for the period excluding any categories
whose name contains 'transfer' (case-insensitive).
These are internal account moves that inflate both sides artificially.
"""
period = request.args.get('period', 'this_month')
date_from, date_to, _ = _parse_date_range(period)
# Find all transfer-like category IDs
transfer_cats = Category.query.filter(
Category.name.ilike('%transfer%')
).all()
transfer_ids = [c.id for c in transfer_cats]
def _sum(txn_type):
q = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == txn_type,
Transaction.date >= date_from,
Transaction.date <= date_to,
)
if transfer_ids:
q = q.filter(
db.or_(
Transaction.category_id.notin_(transfer_ids),
Transaction.category_id.is_(None),
)
)
return float(q.scalar())
def _sum_transfer(txn_type):
if not transfer_ids:
return 0.0
return float(db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == txn_type,
Transaction.date >= date_from,
Transaction.date <= date_to,
Transaction.category_id.in_(transfer_ids),
).scalar())
income = _sum('income')
expense = _sum('expense')
net = income - expense
savings = round(net / income * 100, 1) if income else 0
excluded_income = _sum_transfer('income')
excluded_expense = _sum_transfer('expense')
return jsonify({
'income': income,
'expense': expense,
'net_cash_flow': net,
'savings_rate': savings,
'excluded_income': excluded_income,
'excluded_expense': excluded_expense,
'transfer_categories': [c.name for c in transfer_cats],
})
@dashboard_bp.route('/api/health-score')
@login_required
def health_score_api():
from app.services.health_score_service import compute_health_score
try:
data = compute_health_score()
except Exception as e:
log.warning('[dashboard] health_score_api failed: %s', e)
return jsonify({'error': str(e)}), 500
return jsonify(data)
@dashboard_bp.route('/api/anomalies')
@login_required
def anomalies_api():
from app.services.report_service import spending_anomalies
try:
items = spending_anomalies()
except Exception as e:
log.warning('[dashboard] anomalies_api failed: %s', e)
items = []
return jsonify({'anomalies': items})
@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],
})
@dashboard_bp.route('/api/fx-refresh', methods=['POST'])
@login_required
def fx_refresh():
"""Force-refresh today's USD/VND rate."""
result = force_refresh()
if result:
return jsonify({
'rate': result['rate'],
'date': result['date'].strftime('%b %d, %Y'),
'source': result['source'],
'is_stale': result['is_stale'],
})
return jsonify({'error': 'Could not fetch rate'}), 503