diff --git a/app/__init__.py b/app/__init__.py
index 6246edd..f193214 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -27,6 +27,7 @@ def create_app(config_name=None):
from app.routes.goals import goals_bp
from app.routes.investments import investments_bp
from app.routes.ai import ai_bp
+ from app.routes.reports import reports_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
@@ -37,6 +38,7 @@ def create_app(config_name=None):
app.register_blueprint(goals_bp)
app.register_blueprint(investments_bp)
app.register_blueprint(ai_bp)
+ app.register_blueprint(reports_bp)
with app.app_context():
from app.models import (
diff --git a/app/routes/reports.py b/app/routes/reports.py
new file mode 100644
index 0000000..b3e9e80
--- /dev/null
+++ b/app/routes/reports.py
@@ -0,0 +1,219 @@
+from flask import (Blueprint, render_template, request, redirect, url_for,
+ Response, flash, send_file)
+from flask_login import login_required, current_user
+from app.models.transaction import Transaction
+from app.services.report_service import (
+ monthly_report, quarterly_report, yearly_report,
+ net_worth_history, category_trends, tax_year_summary,
+ take_net_worth_snapshot,
+)
+from app.services.export_service import (
+ transactions_to_csv, transactions_to_excel,
+ report_to_pdf, build_report_html,
+)
+from datetime import date
+import io
+
+reports_bp = Blueprint('reports', __name__, url_prefix='/reports')
+
+_CUR_YEAR = date.today().year
+_CUR_MONTH = date.today().month
+
+
+@reports_bp.route('/')
+@login_required
+def index():
+ today = date.today()
+ # Default: current month summary
+ report = monthly_report(today.year, today.month)
+ nw_history = net_worth_history()
+ cat_trend = category_trends(6)
+
+ years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
+ return render_template('reports/index.html',
+ report=report,
+ nw_history=nw_history,
+ cat_trend=cat_trend,
+ years=years,
+ current_year=_CUR_YEAR,
+ current_month=_CUR_MONTH,
+ report_type='monthly',
+ selected_year=today.year,
+ selected_month=today.month,
+ selected_quarter=None)
+
+
+@reports_bp.route('/monthly')
+@login_required
+def monthly():
+ year = request.args.get('year', _CUR_YEAR, type=int)
+ month = request.args.get('month', _CUR_MONTH, type=int)
+ report = monthly_report(year, month)
+ nw_history = net_worth_history()
+ cat_trend = category_trends(6)
+ years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
+ return render_template('reports/index.html',
+ report=report,
+ nw_history=nw_history,
+ cat_trend=cat_trend,
+ years=years,
+ current_year=_CUR_YEAR,
+ current_month=_CUR_MONTH,
+ report_type='monthly',
+ selected_year=year,
+ selected_month=month,
+ selected_quarter=None)
+
+
+@reports_bp.route('/quarterly')
+@login_required
+def quarterly():
+ year = request.args.get('year', _CUR_YEAR, type=int)
+ quarter = request.args.get('quarter', ((_CUR_MONTH - 1) // 3) + 1, type=int)
+ report = quarterly_report(year, quarter)
+ nw_history = net_worth_history()
+ cat_trend = category_trends(6)
+ years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
+ return render_template('reports/index.html',
+ report=report,
+ nw_history=nw_history,
+ cat_trend=cat_trend,
+ years=years,
+ current_year=_CUR_YEAR,
+ current_month=_CUR_MONTH,
+ report_type='quarterly',
+ selected_year=year,
+ selected_month=None,
+ selected_quarter=quarter)
+
+
+@reports_bp.route('/yearly')
+@login_required
+def yearly():
+ year = request.args.get('year', _CUR_YEAR, type=int)
+ report = yearly_report(year)
+ nw_history = net_worth_history()
+ cat_trend = category_trends(12)
+ years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
+ return render_template('reports/index.html',
+ report=report,
+ nw_history=nw_history,
+ cat_trend=cat_trend,
+ years=years,
+ current_year=_CUR_YEAR,
+ current_month=_CUR_MONTH,
+ report_type='yearly',
+ selected_year=year,
+ selected_month=None,
+ selected_quarter=None)
+
+
+@reports_bp.route('/tax')
+@login_required
+def tax():
+ year = request.args.get('year', _CUR_YEAR, type=int)
+ report = tax_year_summary(year)
+ years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1))
+ return render_template('reports/tax.html',
+ report=report,
+ years=years,
+ selected_year=year)
+
+
+# ── Exports ───────────────────────────────────────────────────────────────────
+
+@reports_bp.route('/export/csv')
+@login_required
+def export_csv():
+ year = request.args.get('year', _CUR_YEAR, type=int)
+ month = request.args.get('month', type=int)
+ txn_type = request.args.get('type', 'all')
+
+ query = Transaction.query.filter(
+ Transaction.date >= date(year, month if month else 1, 1),
+ Transaction.date <= date(year, month if month else 12,
+ 31 if not month else
+ __import__('calendar').monthrange(year, month)[1]),
+ )
+ if txn_type != 'all':
+ query = query.filter(Transaction.transaction_type == txn_type)
+ transactions = query.order_by(Transaction.date.desc()).all()
+
+ csv_data = transactions_to_csv(transactions)
+ label = f'{year}-{month:02d}' if month else str(year)
+ filename = f'pfm_transactions_{label}.csv'
+
+ return Response(
+ csv_data,
+ mimetype='text/csv',
+ headers={'Content-Disposition': f'attachment; filename="{filename}"'}
+ )
+
+
+@reports_bp.route('/export/excel')
+@login_required
+def export_excel():
+ year = request.args.get('year', _CUR_YEAR, type=int)
+ month = request.args.get('month', type=int)
+
+ query = Transaction.query.filter(
+ Transaction.date >= date(year, month if month else 1, 1),
+ Transaction.date <= date(year, month if month else 12,
+ 31 if not month else
+ __import__('calendar').monthrange(year, month)[1]),
+ Transaction.transaction_type.in_(['income', 'expense']),
+ )
+ transactions = query.order_by(Transaction.date.desc()).all()
+
+ label = f'{year}-{month:02d}' if month else str(year)
+ period_label = f'{year} Month {month}' if month else str(year)
+ excel_bytes = transactions_to_excel(transactions, period_label)
+ filename = f'pfm_transactions_{label}.xlsx'
+
+ return send_file(
+ io.BytesIO(excel_bytes),
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ as_attachment=True,
+ download_name=filename,
+ )
+
+
+@reports_bp.route('/export/pdf')
+@login_required
+def export_pdf():
+ report_type = request.args.get('type', 'monthly')
+ year = request.args.get('year', _CUR_YEAR, type=int)
+ month = request.args.get('month', _CUR_MONTH, type=int)
+ quarter = request.args.get('quarter', 1, type=int)
+
+ if report_type == 'yearly':
+ report = yearly_report(year)
+ elif report_type == 'quarterly':
+ report = quarterly_report(year, quarter)
+ else:
+ report = monthly_report(year, month)
+
+ symbol = current_user.currency_symbol or '$'
+ html = build_report_html(report, report_type, symbol)
+ pdf = report_to_pdf(html)
+
+ if pdf is None:
+ flash('PDF generation failed. WeasyPrint may not be installed.', 'danger')
+ return redirect(url_for('reports.index'))
+
+ filename = f'pfm_report_{report["period"].replace(" ", "_")}.pdf'
+ return send_file(
+ io.BytesIO(pdf),
+ mimetype='application/pdf',
+ as_attachment=True,
+ download_name=filename,
+ )
+
+
+@reports_bp.route('/snapshot', methods=['POST'])
+@login_required
+def manual_snapshot():
+ """Manually trigger a net worth snapshot."""
+ snap = take_net_worth_snapshot()
+ flash(f'Net worth snapshot saved: {snap.snapshot_date.strftime("%B %d, %Y")}', 'success')
+ return redirect(url_for('reports.index'))
diff --git a/app/services/export_service.py b/app/services/export_service.py
new file mode 100644
index 0000000..8a4f287
--- /dev/null
+++ b/app/services/export_service.py
@@ -0,0 +1,199 @@
+"""
+Export Service — CSV, Excel, and PDF generation for transactions and reports.
+"""
+
+import io
+import csv
+from datetime import date
+from flask import current_app
+from app.models.transaction import Transaction
+
+
+# ── CSV export ────────────────────────────────────────────────────────────────
+
+def transactions_to_csv(transactions):
+ """Return a CSV string of transactions."""
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ writer.writerow(['Date', 'Type', 'Description', 'Category', 'Account', 'Amount', 'Notes'])
+ for txn in transactions:
+ writer.writerow([
+ txn.date.strftime('%Y-%m-%d'),
+ txn.transaction_type,
+ txn.description,
+ txn.category.name if txn.category else '',
+ txn.account.name if txn.account else '',
+ float(txn.amount),
+ txn.notes or '',
+ ])
+
+ output.seek(0)
+ return output.getvalue()
+
+
+# ── Excel export ──────────────────────────────────────────────────────────────
+
+def transactions_to_excel(transactions, period_label='Transactions'):
+ """Return Excel bytes for a list of transactions."""
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
+ from openpyxl.utils import get_column_letter
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = period_label[:31] # max 31 chars
+
+ symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
+
+ # Header style
+ header_fill = PatternFill(start_color='0F172A', end_color='0F172A', fill_type='solid')
+ header_font = Font(color='F1F5F9', bold=True, size=10)
+ thin = Side(style='thin', color='E2E8F0')
+ border = Border(bottom=Side(style='thin', color='E2E8F0'))
+
+ headers = ['Date', 'Type', 'Description', 'Category', 'Account', f'Amount ({symbol})', 'Notes']
+ col_widths = [12, 10, 40, 18, 18, 16, 30]
+
+ for col, (header, width) in enumerate(zip(headers, col_widths), 1):
+ cell = ws.cell(row=1, column=col, value=header)
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = Alignment(horizontal='left', vertical='center')
+ ws.column_dimensions[get_column_letter(col)].width = width
+
+ ws.row_dimensions[1].height = 22
+
+ # Data rows
+ income_fill = PatternFill(start_color='F0FDF4', end_color='F0FDF4', fill_type='solid')
+ expense_fill = PatternFill(start_color='FFF7F7', end_color='FFF7F7', fill_type='solid')
+
+ for row_num, txn in enumerate(transactions, 2):
+ fill = income_fill if txn.transaction_type == 'income' else expense_fill
+ data = [
+ txn.date.strftime('%Y-%m-%d'),
+ txn.transaction_type.title(),
+ txn.description,
+ txn.category.name if txn.category else '',
+ txn.account.name if txn.account else '',
+ float(txn.amount),
+ txn.notes or '',
+ ]
+ for col, value in enumerate(data, 1):
+ cell = ws.cell(row=row_num, column=col, value=value)
+ cell.fill = fill
+ cell.border = border
+ cell.font = Font(size=10)
+ if col == 6:
+ cell.number_format = f'#,##0.00'
+ cell.alignment = Alignment(horizontal='right')
+
+ # Totals row
+ total_row = len(transactions) + 2
+ ws.cell(row=total_row, column=5, value='TOTAL').font = Font(bold=True, size=10)
+ total_cell = ws.cell(row=total_row, column=6,
+ value=sum(float(t.amount) for t in transactions))
+ total_cell.font = Font(bold=True, size=10)
+ total_cell.number_format = f'#,##0.00'
+ total_cell.alignment = Alignment(horizontal='right')
+
+ output = io.BytesIO()
+ wb.save(output)
+ output.seek(0)
+ return output.getvalue()
+
+
+# ── PDF export ────────────────────────────────────────────────────────────────
+
+def report_to_pdf(html_content):
+ """Convert HTML string to PDF bytes using WeasyPrint."""
+ try:
+ from weasyprint import HTML, CSS
+ pdf = HTML(string=html_content).write_pdf()
+ return pdf
+ except Exception as e:
+ import logging
+ logging.getLogger(__name__).error(f'[export] PDF generation failed: {e}')
+ return None
+
+
+def build_report_html(report_data, report_type, currency_symbol='$'):
+ """Build a clean HTML string for PDF export."""
+ today = date.today().strftime('%B %d, %Y')
+ period = report_data.get('period', '')
+ income = report_data.get('income', 0)
+ expense = report_data.get('expense', 0)
+ net = report_data.get('net', 0)
+ savings_rate = report_data.get('savings_rate', 0)
+
+ def fmt(v):
+ return f"{currency_symbol}{abs(float(v)):,.2f}"
+
+ # Expense categories table rows
+ cat_rows = ''
+ total_exp = sum(c['total'] for c in report_data.get('expense_categories', []))
+ for cat in report_data.get('expense_categories', []):
+ pct = round(cat['total'] / total_exp * 100, 1) if total_exp > 0 else 0
+ cat_rows += f'''
+
+ | {cat['name']} |
+ {fmt(cat['total'])} |
+ {pct}% |
+
'''
+
+ html = f'''
+
+
+
+
+
+
+Personal Finance Report — {period}
+Generated {today}
+
+
+
+
Income
+
{fmt(income)}
+
+
+
Expenses
+
{fmt(expense)}
+
+
+
+
Savings Rate
+
{savings_rate}%
+
+
+
+Expense Breakdown
+
+ | Category | Amount | % of Total |
+ {cat_rows or '| No expenses |
'}
+
+
+
+
+'''
+ return html
diff --git a/app/services/report_service.py b/app/services/report_service.py
new file mode 100644
index 0000000..9467aae
--- /dev/null
+++ b/app/services/report_service.py
@@ -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
diff --git a/app/templates/base.html b/app/templates/base.html
index ee13e36..6925603 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -200,7 +200,7 @@
Investments
-
+
Reports
diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html
new file mode 100644
index 0000000..36aeb8f
--- /dev/null
+++ b/app/templates/reports/index.html
@@ -0,0 +1,281 @@
+{% extends "base.html" %}
+{% block title %}Reports{% endblock %}
+{% block page_title %}Reports{% endblock %}
+
+{% block extra_css %}
+.report-tab { font-size:13px; border-radius:6px; padding:6px 14px; border:1px solid var(--border); background:var(--card-bg); color:var(--muted); text-decoration:none; transition:all .15s; }
+.report-tab:hover { background:#f1f5f9; color:var(--text); }
+.report-tab.active { background:#0f172a; color:#f1f5f9; border-color:#0f172a; }
+{% endblock %}
+
+{% block topbar_actions %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
Income
+
{{ report.income | currency }}
+
+
+
+
+
Expenses
+
{{ report.expense | currency }}
+
+
+
+
+
Net
+
{{ report.net | currency }}
+
+
+
+
+
Savings Rate
+
{{ report.savings_rate }}%
+
+
+
+
+
+
+
+
+
+
+ {% if report_type == 'monthly' %}Income vs Expenses — {{ report.period }}
+ {% elif report_type == 'quarterly' %}Monthly Breakdown — {{ report.period }}
+ {% else %}Monthly Breakdown — {{ report.period }}
+ {% endif %}
+
+
+
+
+
+
+
+
+
Expense Breakdown
+ {% if report.expense_categories %}
+
+
+ {% for cat in report.expense_categories[:5] %}
+
+ {{ cat.name }}
+ {{ cat.total | currency }}
+
+ {% endfor %}
+
+ {% else %}
+
No expenses this period.
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ Net Worth History
+
+
+ {% if nw_history.count > 1 %}
+
+ {% else %}
+
Not enough snapshots yet. Snapshots save automatically on the 1st of each month, or click "Snapshot Now".
+ {% endif %}
+
+
+
+
+
+
+
+
+
Spending Trends — Top Categories (Last 6 Months)
+ {% if cat_trend.datasets %}
+
+ {% else %}
+
Not enough data for trends yet.
+ {% endif %}
+
+
+
+
+
+{% if report_type == 'yearly' %}
+
+
+
+
Avg Monthly Income
+
{{ report.avg_monthly_income | currency }}
+
+
+
+
+
Avg Monthly Expense
+
{{ report.avg_monthly_expense | currency }}
+
+
+
+{% endif %}
+{% endblock %}
+
+{% block extra_js %}
+
+
+{% endblock %}
diff --git a/app/templates/reports/tax.html b/app/templates/reports/tax.html
new file mode 100644
index 0000000..aa1ac56
--- /dev/null
+++ b/app/templates/reports/tax.html
@@ -0,0 +1,147 @@
+{% extends "base.html" %}
+{% block title %}Tax Summary {{ report.year }}{% endblock %}
+{% block page_title %}Tax Year Summary{% endblock %}
+
+{% block topbar_actions %}
+
+ Export CSV
+
+{% endblock %}
+
+{% block content %}
+
+
+
+ {{ report.date_from.strftime('%b %d, %Y') }} – {{ report.date_to.strftime('%b %d, %Y') }}
+
+
+
+
+
+
+
Total Income
+
{{ report.total_income | currency }}
+
+
+
+
+
Total Expenses
+
{{ report.total_expense | currency }}
+
+
+
+
+
Net for Year
+
{{ report.net | currency }}
+
+
+
+
+
Income Sources
+
{{ report.income_categories | length }}
+
+
+
+
+
+
+
+
+
+ Income by Source
+
+
+ | Category | Total |
+
+ {% if report.income_categories %}
+ {% for cat in report.income_categories %}
+
+ |
+
+ |
+ {{ cat.total | currency }} |
+
+ {% endfor %}
+
+ | Total |
+ {{ report.total_income | currency }} |
+
+ {% else %}
+ | No income recorded |
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ Expenses by Category
+
+
+ | Category | Total |
+
+ {% if report.expense_categories %}
+ {% for cat in report.expense_categories %}
+
+ |
+
+ |
+ {{ cat.total | currency }} |
+
+ {% endfor %}
+
+ | Total |
+ {{ report.total_expense | currency }} |
+
+ {% else %}
+ | No expenses recorded |
+ {% endif %}
+
+
+
+
+
+
+
+{% if report.income_transactions %}
+
+
+ All Income — {{ report.year }}
+ {{ report.income_transactions | length }} transactions
+
+
+ | Date | Description | Category | Amount |
+
+ {% for txn in report.income_transactions %}
+
+ | {{ txn.date.strftime('%b %d, %Y') }} |
+ {{ txn.description }} |
+ {{ txn.category.name if txn.category else '—' }} |
+ +{{ txn.amount | currency }} |
+
+ {% endfor %}
+
+
+
+{% endif %}
+
+
+{% endblock %}
diff --git a/scripts/daily_snapshot.py b/scripts/daily_snapshot.py
new file mode 100644
index 0000000..dcd699d
--- /dev/null
+++ b/scripts/daily_snapshot.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+"""
+Cron script: save monthly net worth snapshot.
+Run by systemd timer pfm-snapshot.timer on 1st of each month at 00:05.
+"""
+
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app import create_app
+from app.services.report_service import take_net_worth_snapshot
+
+app = create_app()
+
+if __name__ == '__main__':
+ with app.app_context():
+ print('[snapshot] Saving net worth snapshot...')
+ snap = take_net_worth_snapshot()
+ print(f'[snapshot] Net worth on {snap.snapshot_date}: '
+ f'assets={snap.total_assets}, '
+ f'liabilities={snap.total_liabilities}, '
+ f'net={snap.net_worth}')