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/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