05/31 Phase 6

This commit is contained in:
2026-05-31 16:55:43 -04:00
parent b3d495e57c
commit 46a604a73a
8 changed files with 1172 additions and 1 deletions
+300
View File
@@ -0,0 +1,300 @@
"""
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