Files
Personal-Finance-Management/app/services/report_service.py
T

486 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Report Service — aggregates data for monthly, quarterly, yearly,
net worth history, category trends, and tax year reports.
"""
import calendar
from collections import defaultdict
from datetime import date, datetime, timedelta
from sqlalchemy import func, extract
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():
from dateutil.relativedelta import relativedelta
snapshots = NetWorthSnapshot.query\
.order_by(NetWorthSnapshot.snapshot_date.asc())\
.all()
result = {
'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),
'proj_labels': [],
'proj_values': [],
'projected_1yr': None,
'monthly_delta': None,
}
if len(snapshots) >= 3:
recent = snapshots[-6:] # up to last 6 data points
deltas = [
float(recent[i].net_worth) - float(recent[i - 1].net_worth)
for i in range(1, len(recent))
]
avg_delta = sum(deltas) / len(deltas)
last_nw = float(snapshots[-1].net_worth)
last_date = snapshots[-1].snapshot_date
proj_labels, proj_values = [], []
for i in range(1, 13):
proj_labels.append((last_date + relativedelta(months=i)).strftime('%b %Y'))
proj_values.append(round(last_nw + avg_delta * i, 2))
result['proj_labels'] = proj_labels
result['proj_values'] = proj_values
result['projected_1yr'] = proj_values[-1]
result['monthly_delta'] = round(avg_delta, 2)
return result
# ── 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,
}
# ── Month-over-month category comparison ─────────────────────────────────────
def category_mom_comparison():
"""
Returns per-category expense totals for: this month, last month, and the
3-month rolling average (last 3 complete months). Sorted by this-month
spend descending.
"""
today = date.today()
this_start = today.replace(day=1)
this_end = today
last_end = this_start - timedelta(days=1)
last_start = last_end.replace(day=1)
# Build month ranges for the 3-month rolling average (the 3 complete months
# ending with last month)
avg_ranges = []
cursor = last_start
for _ in range(3):
me = cursor - timedelta(days=1)
ms = me.replace(day=1)
avg_ranges.append((ms, me))
cursor = ms
def _totals_by_cat(start, end):
rows = db.session.query(
Category.id,
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 >= start,
Transaction.date <= end,
).group_by(Category.id).all()
return {r.id: {'name': r.name, 'color': r.color, 'icon': r.icon,
'total': float(r.total)} for r in rows}
this_data = _totals_by_cat(this_start, this_end)
last_data = _totals_by_cat(last_start, last_end)
avg_data = [_totals_by_cat(s, e) for s, e in avg_ranges]
# Collect all known category IDs + their metadata
cat_meta = {}
for src in [this_data, last_data] + avg_data:
for cid, info in src.items():
if cid not in cat_meta:
cat_meta[cid] = {k: info[k] for k in ('name', 'color', 'icon')}
rows = []
for cid, meta in cat_meta.items():
this_amt = this_data.get(cid, {}).get('total', 0.0)
last_amt = last_data.get(cid, {}).get('total', 0.0)
avg_monthly = (
sum(md.get(cid, {}).get('total', 0.0) for md in avg_data) / len(avg_data)
if avg_data else 0.0
)
change_pct = (
round((this_amt - last_amt) / last_amt * 100, 1)
if last_amt > 0 else None
)
rows.append({
**meta,
'id': cid,
'this_month': round(this_amt, 2),
'last_month': round(last_amt, 2),
'avg_3mo': round(avg_monthly, 2),
'change_pct': change_pct,
})
rows.sort(key=lambda r: r['this_month'], reverse=True)
return rows
# ── Spending anomaly detection ────────────────────────────────────────────────
def spending_anomalies(days_back=30, multiplier=2.0, min_avg=10.0, min_amount=10.0):
"""
Find transactions in the last `days_back` days whose amount is more than
`multiplier` × the category's average monthly spend over the prior 3 months.
Returns a list of dicts with transaction details + context, capped at 5.
"""
today = date.today()
window_start = today - timedelta(days=days_back)
# Baseline: the 3 complete months before today's month
base_end = today.replace(day=1) - timedelta(days=1)
base_start = (base_end.replace(day=1) - timedelta(days=60)).replace(day=1)
# Per-category, per-month totals over baseline
rows = db.session.query(
Transaction.category_id,
extract('year', Transaction.date).label('yr'),
extract('month', Transaction.date).label('mo'),
func.sum(Transaction.amount).label('total'),
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= base_start,
Transaction.date <= base_end,
Transaction.category_id.isnot(None),
).group_by(
Transaction.category_id,
extract('year', Transaction.date),
extract('month', Transaction.date),
).all()
cat_month_totals = defaultdict(list)
for r in rows:
cat_month_totals[r.category_id].append(float(r.total))
cat_avg = {
cid: sum(totals) / len(totals)
for cid, totals in cat_month_totals.items()
}
# Recent transactions in the window
recent = (
Transaction.query
.filter(
Transaction.transaction_type == 'expense',
Transaction.date >= window_start,
Transaction.date <= today,
Transaction.category_id.isnot(None),
)
.order_by(Transaction.date.desc())
.all()
)
anomalies = []
for txn in recent:
avg = cat_avg.get(txn.category_id)
amt = float(txn.amount)
if avg and avg >= min_avg and amt >= min_amount and amt > avg * multiplier:
anomalies.append({
'id': txn.id,
'date': txn.date.strftime('%b %d'),
'description': txn.description,
'amount': amt,
'category': txn.category.name if txn.category else 'Other',
'category_color': txn.category.color if txn.category else '#94a3b8',
'category_icon': txn.category.icon if txn.category else 'bi-tag',
'avg': round(avg, 2),
'multiple': round(amt / avg, 1),
})
# Sort by multiple desc (biggest outliers first), cap at 5
anomalies.sort(key=lambda a: a['multiple'], reverse=True)
return anomalies[:5]
# ── 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