""" Report Service — aggregates data for monthly, quarterly, yearly, net worth history, category trends, and tax year reports. """ import calendar from datetime import date, datetime from sqlalchemy import func from app.extensions import db from app.models.transaction import Transaction from app.models.category import Category from app.models.account import Account from app.models.net_worth_snapshot import NetWorthSnapshot from app.models.investment import Investment # ── Helpers ─────────────────────────────────────────────────────────────────── def _month_range(year, month): last = calendar.monthrange(year, month)[1] return date(year, month, 1), date(year, month, last) def _quarter_range(year, quarter): start_month = (quarter - 1) * 3 + 1 end_month = start_month + 2 _, last = calendar.monthrange(year, end_month) return date(year, start_month, 1), date(year, end_month, last) def _year_range(year): return date(year, 1, 1), date(year, 12, 31) def _totals(date_from, date_to): """Return (income, expense) totals for a date range.""" inc = db.session.query( func.coalesce(func.sum(Transaction.amount), 0) ).filter( Transaction.transaction_type == 'income', Transaction.date >= date_from, Transaction.date <= date_to, ).scalar() exp = db.session.query( func.coalesce(func.sum(Transaction.amount), 0) ).filter( Transaction.transaction_type == 'expense', Transaction.date >= date_from, Transaction.date <= date_to, ).scalar() return float(inc), float(exp) def _category_breakdown(date_from, date_to, txn_type='expense'): rows = 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 == txn_type, Transaction.date >= date_from, Transaction.date <= date_to, ).group_by(Category.id)\ .order_by(func.sum(Transaction.amount).desc())\ .all() return [{'name': r.name, 'color': r.color, 'icon': r.icon, 'total': float(r.total)} for r in rows] # ── Monthly report ──────────────────────────────────────────────────────────── def monthly_report(year, month): date_from, date_to = _month_range(year, month) income, expense = _totals(date_from, date_to) expense_cats = _category_breakdown(date_from, date_to, 'expense') income_cats = _category_breakdown(date_from, date_to, 'income') transactions = Transaction.query\ .filter( Transaction.transaction_type.in_(['income', 'expense']), Transaction.date >= date_from, Transaction.date <= date_to, ).order_by(Transaction.date.desc()).all() return { 'period': f'{date_from.strftime("%B %Y")}', 'date_from': date_from, 'date_to': date_to, 'income': income, 'expense': expense, 'net': income - expense, 'savings_rate': round(((income - expense) / income * 100), 1) if income > 0 else 0, 'expense_categories': expense_cats, 'income_categories': income_cats, 'transactions': transactions, 'transaction_count': len(transactions), } # ── Quarterly report ────────────────────────────────────────────────────────── def quarterly_report(year, quarter): date_from, date_to = _quarter_range(year, quarter) income, expense = _totals(date_from, date_to) expense_cats = _category_breakdown(date_from, date_to, 'expense') # Monthly breakdown within the quarter months = [] start_month = (quarter - 1) * 3 + 1 for m in range(start_month, start_month + 3): mf, mt = _month_range(year, m) mi, me = _totals(mf, mt) months.append({ 'label': date(year, m, 1).strftime('%B'), 'income': mi, 'expense': me, 'net': mi - me, }) return { 'period': f'Q{quarter} {year}', 'date_from': date_from, 'date_to': date_to, 'income': income, 'expense': expense, 'net': income - expense, 'savings_rate': round(((income - expense) / income * 100), 1) if income > 0 else 0, 'expense_categories': expense_cats, 'months': months, } # ── Yearly report ───────────────────────────────────────────────────────────── def yearly_report(year): date_from, date_to = _year_range(year) income, expense = _totals(date_from, date_to) expense_cats = _category_breakdown(date_from, date_to, 'expense') income_cats = _category_breakdown(date_from, date_to, 'income') # Monthly breakdown months = [] for m in range(1, 13): mf, mt = _month_range(year, m) mi, me = _totals(mf, mt) months.append({ 'label': date(year, m, 1).strftime('%b'), 'income': mi, 'expense': me, 'net': mi - me, }) return { 'period': str(year), 'date_from': date_from, 'date_to': date_to, 'income': income, 'expense': expense, 'net': income - expense, 'savings_rate': round(((income - expense) / income * 100), 1) if income > 0 else 0, 'avg_monthly_income': round(income / 12, 2), 'avg_monthly_expense': round(expense / 12, 2), 'expense_categories': expense_cats, 'income_categories': income_cats, 'months': months, } # ── Net worth history ───────────────────────────────────────────────────────── def net_worth_history(): snapshots = NetWorthSnapshot.query\ .order_by(NetWorthSnapshot.snapshot_date.asc())\ .all() return { 'snapshots': snapshots, 'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots], 'values': [float(s.net_worth) for s in snapshots], 'assets': [float(s.total_assets) for s in snapshots], 'liabilities': [float(s.total_liabilities) for s in snapshots], 'count': len(snapshots), } # ── Category spending trends (last 6 months) ────────────────────────────────── def category_trends(months_back=6): today = date.today() result = {} labels = [] for i in range(months_back - 1, -1, -1): # Walk back i months if today.month - i <= 0: yr = today.year - 1 mo = 12 + (today.month - i) else: yr = today.year mo = today.month - i mf, mt = _month_range(yr, mo) label = date(yr, mo, 1).strftime('%b %Y') labels.append(label) rows = db.session.query( Category.name, Category.color, func.sum(Transaction.amount).label('total') ).join(Transaction, Transaction.category_id == Category.id)\ .filter( Transaction.transaction_type == 'expense', Transaction.date >= mf, Transaction.date <= mt, ).group_by(Category.id).all() for row in rows: if row.name not in result: result[row.name] = {'color': row.color, 'data': [0] * months_back} idx = months_back - 1 - i result[row.name]['data'][idx] = float(row.total) # Keep top 6 categories by total sorted_cats = sorted(result.items(), key=lambda x: sum(x[1]['data']), reverse=True)[:6] datasets = [] for name, info in sorted_cats: datasets.append({ 'label': name, 'data': info['data'], 'borderColor': info['color'], 'backgroundColor': info['color'] + '22', 'tension': 0.3, 'fill': False, }) return {'labels': labels, 'datasets': datasets} # ── Tax year summary ────────────────────────────────────────────────────────── def tax_year_summary(year): date_from, date_to = _year_range(year) income, expense = _totals(date_from, date_to) income_cats = _category_breakdown(date_from, date_to, 'income') expense_cats = _category_breakdown(date_from, date_to, 'expense') # All income transactions for the year income_txns = Transaction.query\ .filter( Transaction.transaction_type == 'income', Transaction.date >= date_from, Transaction.date <= date_to, ).order_by(Transaction.date.asc()).all() return { 'year': year, 'date_from': date_from, 'date_to': date_to, 'total_income': income, 'total_expense': expense, 'net': income - expense, 'income_categories': income_cats, 'expense_categories': expense_cats, 'income_transactions': income_txns, } # ── Snapshot helpers ────────────────────────────────────────────────────────── def take_net_worth_snapshot(): """Save today's net worth snapshot. Called by cron on 1st of month.""" today = date.today() existing = NetWorthSnapshot.query.filter_by(snapshot_date=today).first() if existing: return existing accounts = Account.query.filter_by(is_active=True).all() total_assets = sum(float(a.balance) for a in accounts if float(a.balance) > 0 and a.account_type != 'credit_card') total_liab = sum(abs(float(a.balance)) for a in accounts if float(a.balance) < 0) investments = Investment.query.filter_by(is_active=True).all() inv_value = sum(i.current_value for i in investments) account_balances = {a.name: float(a.balance) for a in accounts} snapshot = NetWorthSnapshot( snapshot_date=today, total_assets=total_assets + inv_value, total_liabilities=total_liab, net_worth=(total_assets + inv_value) - total_liab, account_balances=account_balances, investment_value=inv_value, ) db.session.add(snapshot) db.session.commit() return snapshot