From 46a604a73a2ba667ea570ad02a8a26c1f32fc611 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sun, 31 May 2026 16:55:43 -0400 Subject: [PATCH] 05/31 Phase 6 --- app/__init__.py | 2 + app/routes/reports.py | 219 ++++++++++++++++++++++ app/services/export_service.py | 199 ++++++++++++++++++++ app/services/report_service.py | 300 +++++++++++++++++++++++++++++++ app/templates/base.html | 2 +- app/templates/reports/index.html | 281 +++++++++++++++++++++++++++++ app/templates/reports/tax.html | 147 +++++++++++++++ scripts/daily_snapshot.py | 23 +++ 8 files changed, 1172 insertions(+), 1 deletion(-) create mode 100644 app/routes/reports.py create mode 100644 app/services/export_service.py create mode 100644 app/services/report_service.py create mode 100644 app/templates/reports/index.html create mode 100644 app/templates/reports/tax.html create mode 100644 scripts/daily_snapshot.py 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)}
+
+
+
Net
+
{fmt(net)}
+
+
+
Savings Rate
+
{savings_rate}%
+
+
+ +

Expense Breakdown

+ + + {cat_rows or ''} +
CategoryAmount% of Total
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 %} + +
+ + CSV + + + Excel + + + PDF + +
+{% endblock %} + +{% block content %} + +
+ + + +
+ + {% if report_type == 'monthly' %} + + {% elif report_type == 'quarterly' %} + + {% endif %} + +
+
+ + +
+
+
+
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 +
+ + + + {% if report.income_categories %} + {% for cat in report.income_categories %} + + + + + {% endfor %} + + + + + {% else %} + + {% endif %} + +
CategoryTotal
+
+
+ {{ cat.name }} +
+
{{ cat.total | currency }}
Total{{ report.total_income | currency }}
No income recorded
+
+
+ + +
+
+
+ Expenses by Category +
+ + + + {% if report.expense_categories %} + {% for cat in report.expense_categories %} + + + + + {% endfor %} + + + + + {% else %} + + {% endif %} + +
CategoryTotal
+
+
+ {{ cat.name }} +
+
{{ cat.total | currency }}
Total{{ report.total_expense | currency }}
No expenses recorded
+
+
+
+ + +{% if report.income_transactions %} +
+
+ All Income — {{ report.year }} + {{ report.income_transactions | length }} transactions +
+ + + + {% for txn in report.income_transactions %} + + + + + + + {% endfor %} + +
DateDescriptionCategoryAmount
{{ txn.date.strftime('%b %d, %Y') }}{{ txn.description }}{{ txn.category.name if txn.category else '—' }}+{{ txn.amount | currency }}
+
+{% endif %} + +
+ Back to Reports +
+{% 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}')