291 lines
11 KiB
Python
291 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/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
|