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
+2
View File
@@ -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 (
+219
View File
@@ -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'))
+199
View File
@@ -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'''
<tr>
<td>{cat['name']}</td>
<td style="text-align:right">{fmt(cat['total'])}</td>
<td style="text-align:right">{pct}%</td>
</tr>'''
html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; font-size: 12px; color: #1e293b; margin: 40px; }}
h1 {{ font-size: 22px; color: #0f172a; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }}
h2 {{ font-size: 14px; color: #475569; margin-top: 24px; }}
.summary {{ display: flex; gap: 20px; margin: 16px 0; }}
.stat {{ background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; flex: 1; }}
.stat-label {{ font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: #94a3b8; }}
.stat-value {{ font-size: 18px; font-weight: bold; margin-top: 4px; }}
.income {{ color: #10b981; }}
.expense {{ color: #ef4444; }}
.net-pos {{ color: #3b82f6; }}
.net-neg {{ color: #ef4444; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 8px; }}
th {{ background: #0f172a; color: #f1f5f9; padding: 8px 10px; font-size: 10px; text-align: left; }}
td {{ padding: 7px 10px; border-bottom: 1px solid #f1f5f9; }}
tr:nth-child(even) {{ background: #f8fafc; }}
.footer {{ margin-top: 30px; font-size: 10px; color: #94a3b8; text-align: center; }}
</style>
</head>
<body>
<h1>Personal Finance Report — {period}</h1>
<p style="color:#94a3b8;font-size:11px;">Generated {today}</p>
<div class="summary">
<div class="stat">
<div class="stat-label">Income</div>
<div class="stat-value income">{fmt(income)}</div>
</div>
<div class="stat">
<div class="stat-label">Expenses</div>
<div class="stat-value expense">{fmt(expense)}</div>
</div>
<div class="stat">
<div class="stat-label">Net</div>
<div class="stat-value {'net-pos' if net >= 0 else 'net-neg'}">{fmt(net)}</div>
</div>
<div class="stat">
<div class="stat-label">Savings Rate</div>
<div class="stat-value {'net-pos' if net >= 0 else 'net-neg'}">{savings_rate}%</div>
</div>
</div>
<h2>Expense Breakdown</h2>
<table>
<thead><tr><th>Category</th><th style="text-align:right">Amount</th><th style="text-align:right">% of Total</th></tr></thead>
<tbody>{cat_rows or '<tr><td colspan="3" style="color:#94a3b8;">No expenses</td></tr>'}</tbody>
</table>
<div class="footer">PFM Personal Finance · pfm.ngodanguyen.tech</div>
</body>
</html>'''
return html
+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
+1 -1
View File
@@ -200,7 +200,7 @@
<a href="{{ url_for('investments.index') }}" class="sb-link {% if request.blueprint == 'investments' %}active{% endif %}">
<i class="bi bi-graph-up-arrow"></i><span class="lt">Investments</span>
</a>
<a href="#" class="sb-link">
<a href="{{ url_for('reports.index') }}" class="sb-link {% if request.blueprint == 'reports' %}active{% endif %}">
<i class="bi bi-file-earmark-bar-graph"></i><span class="lt">Reports</span>
</a>
+281
View File
@@ -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 %}
<!-- Export buttons -->
<div class="d-flex gap-1">
<a href="{{ url_for('reports.export_csv',
year=selected_year, month=selected_month or '') }}"
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export CSV">
<i class="bi bi-filetype-csv me-1"></i>CSV
</a>
<a href="{{ url_for('reports.export_excel',
year=selected_year, month=selected_month or '') }}"
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export Excel">
<i class="bi bi-file-earmark-excel me-1"></i>Excel
</a>
<a href="{{ url_for('reports.export_pdf',
type=report_type, year=selected_year,
month=selected_month or '', quarter=selected_quarter or '') }}"
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export PDF">
<i class="bi bi-file-earmark-pdf me-1"></i>PDF
</a>
</div>
{% endblock %}
{% block content %}
<!-- Report type tabs + period selector -->
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div class="d-flex gap-1 flex-wrap">
<a href="{{ url_for('reports.monthly', year=selected_year, month=selected_month or current_month) }}"
class="report-tab {% if report_type=='monthly' %}active{% endif %}">Monthly</a>
<a href="{{ url_for('reports.quarterly', year=selected_year, quarter=selected_quarter or 1) }}"
class="report-tab {% if report_type=='quarterly' %}active{% endif %}">Quarterly</a>
<a href="{{ url_for('reports.yearly', year=selected_year) }}"
class="report-tab {% if report_type=='yearly' %}active{% endif %}">Yearly</a>
<a href="{{ url_for('reports.tax', year=selected_year) }}"
class="report-tab">Tax Year</a>
</div>
<!-- Period picker -->
<form method="GET" action="{{ url_for('reports.' + report_type) }}" class="d-flex gap-2 align-items-center">
<select name="year" class="form-select form-select-sm" style="width:auto;">
{% for y in years %}
<option value="{{ y }}" {% if y == selected_year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
{% if report_type == 'monthly' %}
<select name="month" class="form-select form-select-sm" style="width:auto;">
{% for m in range(1, 13) %}
<option value="{{ m }}" {% if m == selected_month %}selected{% endif %}>
{{ ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][m-1] }}
</option>
{% endfor %}
</select>
{% elif report_type == 'quarterly' %}
<select name="quarter" class="form-select form-select-sm" style="width:auto;">
{% for q in range(1, 5) %}
<option value="{{ q }}" {% if q == selected_quarter %}selected{% endif %}>Q{{ q }}</option>
{% endfor %}
</select>
{% endif %}
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;">Go</button>
</form>
</div>
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">Income</div>
<div class="stat-value text-income">{{ report.income | currency }}</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">Expenses</div>
<div class="stat-value text-expense">{{ report.expense | currency }}</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">Net</div>
<div class="stat-value {% if report.net >= 0 %}text-income{% else %}text-expense{% endif %}">{{ report.net | currency }}</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">Savings Rate</div>
<div class="stat-value {% if report.savings_rate >= 0 %}text-invest{% else %}text-expense{% endif %}">{{ report.savings_rate }}%</div>
</div>
</div>
</div>
<!-- Charts row -->
<div class="row g-3 mb-4">
<!-- Period chart: bar for monthly, monthly bars for quarterly/yearly -->
<div class="col-12 col-lg-7">
<div class="pcard h-100">
<div class="pcard-title mb-3">
{% if report_type == 'monthly' %}Income vs Expenses — {{ report.period }}
{% elif report_type == 'quarterly' %}Monthly Breakdown — {{ report.period }}
{% else %}Monthly Breakdown — {{ report.period }}
{% endif %}
</div>
<div style="position:relative;height:240px;"><canvas id="periodChart"></canvas></div>
</div>
</div>
<!-- Expense donut -->
<div class="col-12 col-lg-5">
<div class="pcard h-100">
<div class="pcard-title mb-3">Expense Breakdown</div>
{% if report.expense_categories %}
<div style="position:relative;height:180px;"><canvas id="expDonut"></canvas></div>
<div class="mt-3">
{% for cat in report.expense_categories[:5] %}
<div class="d-flex justify-content-between py-1" style="font-size:12px;border-bottom:1px solid var(--border);">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ cat.color }};margin-right:6px;"></span>{{ cat.name }}</span>
<span class="mono">{{ cat.total | currency }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted small">No expenses this period.</p>
{% endif %}
</div>
</div>
</div>
<!-- Net worth history -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="pcard">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="pcard-title mb-0">Net Worth History</span>
<form method="POST" action="{{ url_for('reports.manual_snapshot') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:11px;" title="Save today's snapshot">
<i class="bi bi-camera me-1"></i>Snapshot Now
</button>
</form>
</div>
{% if nw_history.count > 1 %}
<div style="position:relative;height:200px;"><canvas id="nwChart"></canvas></div>
{% else %}
<p class="text-muted small">Not enough snapshots yet. Snapshots save automatically on the 1st of each month, or click "Snapshot Now".</p>
{% endif %}
</div>
</div>
</div>
<!-- Category spending trends -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="pcard">
<div class="pcard-title mb-3">Spending Trends — Top Categories (Last 6 Months)</div>
{% if cat_trend.datasets %}
<div style="position:relative;height:220px;"><canvas id="trendChart"></canvas></div>
{% else %}
<p class="text-muted small">Not enough data for trends yet.</p>
{% endif %}
</div>
</div>
</div>
<!-- Yearly avg (only for yearly report) -->
{% if report_type == 'yearly' %}
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="pcard pcard-sm text-center">
<div class="pcard-title">Avg Monthly Income</div>
<div class="mono text-income fw-bold" style="font-size:16px;">{{ report.avg_monthly_income | currency }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="pcard pcard-sm text-center">
<div class="pcard-title">Avg Monthly Expense</div>
<div class="mono text-expense fw-bold" style="font-size:16px;">{{ report.avg_monthly_expense | currency }}</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
const sym = '{{ current_user.currency_symbol }}';
const fmtCur = v => sym + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits:0, maximumFractionDigits:0});
// Period chart
(function(){
{% if report_type == 'monthly' %}
const ctx = document.getElementById('periodChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: ['{{ report.period }}'],
datasets: [
{ label:'Income', data:[{{ report.income }}], backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:2, borderRadius:6 },
{ label:'Expenses', data:[{{ report.expense }}], backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:2, borderRadius:6 },
]
},
options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false } } } }
});
{% else %}
const months = {{ report.months | map(attribute='label') | list | tojson }};
const incomes = {{ report.months | map(attribute='income') | list | tojson }};
const expenses = {{ report.months | map(attribute='expense') | list | tojson }};
const ctx = document.getElementById('periodChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: months,
datasets: [
{ label:'Income', data:incomes, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:2, borderRadius:4 },
{ label:'Expenses', data:expenses, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:2, borderRadius:4 },
]
},
options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:11 } } } } }
});
{% endif %}
})();
// Expense donut
{% if report.expense_categories %}
(function(){
const ctx = document.getElementById('expDonut').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: {{ report.expense_categories | map(attribute='name') | list | tojson }},
datasets: [{ data: {{ report.expense_categories | map(attribute='total') | list | tojson }}, backgroundColor: {{ report.expense_categories | map(attribute='color') | list | tojson }}, borderWidth:2, borderColor:'#fff', hoverOffset:4 }]
},
options: { responsive:true, maintainAspectRatio:false, cutout:'65%', plugins:{ legend:{ display:false }, tooltip:{ callbacks:{ label: ctx => ' ' + fmtCur(ctx.parsed) } } } }
});
})();
{% endif %}
// Net worth history
{% if nw_history.count > 1 %}
(function(){
const ctx = document.getElementById('nwChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: {{ nw_history.labels | tojson }},
datasets: [
{ label:'Net Worth', data: {{ nw_history.values | tojson }}, borderColor:'#3b82f6', backgroundColor:'#3b82f611', borderWidth:2, pointRadius:3, tension:.3, fill:true },
{ label:'Assets', data: {{ nw_history.assets | tojson }}, borderColor:'#10b981', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[4,3] },
]
},
options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:10 } } } } }
});
})();
{% endif %}
// Category trends
{% if cat_trend.datasets %}
(function(){
const ctx = document.getElementById('trendChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: { labels: {{ cat_trend.labels | tojson }}, datasets: {{ cat_trend.datasets | tojson }} },
options: {
responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 }, boxWidth:10 } } },
scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:11 } } } }
}
});
})();
{% endif %}
</script>
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Tax Summary {{ report.year }}{% endblock %}
{% block page_title %}Tax Year Summary{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('reports.export_csv', year=selected_year) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
<i class="bi bi-filetype-csv me-1"></i>Export CSV
</a>
{% endblock %}
{% block content %}
<!-- Year selector -->
<div class="d-flex align-items-center gap-3 mb-4">
<form method="GET" action="{{ url_for('reports.tax') }}" class="d-flex gap-2">
<select name="year" class="form-select form-select-sm" style="width:auto;">
{% for y in years %}
<option value="{{ y }}" {% if y == selected_year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;">Go</button>
</form>
<span class="text-muted" style="font-size:12px;">{{ report.date_from.strftime('%b %d, %Y') }} {{ report.date_to.strftime('%b %d, %Y') }}</span>
</div>
<!-- Summary -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label">Total Income</div>
<div class="stat-value text-income">{{ report.total_income | currency }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label">Total Expenses</div>
<div class="stat-value text-expense">{{ report.total_expense | currency }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label">Net for Year</div>
<div class="stat-value {% if report.net >= 0 %}text-income{% else %}text-expense{% endif %}">{{ report.net | currency }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label">Income Sources</div>
<div class="stat-value text-invest">{{ report.income_categories | length }}</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Income by category -->
<div class="col-12 col-lg-6">
<div class="pcard p-0">
<div class="px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Income by Source</span>
</div>
<table class="pfm-table">
<thead><tr><th style="padding-left:20px;">Category</th><th class="text-end" style="padding-right:20px;">Total</th></tr></thead>
<tbody>
{% if report.income_categories %}
{% for cat in report.income_categories %}
<tr>
<td style="padding-left:20px;">
<div class="d-flex align-items-center gap-2">
<div style="width:8px;height:8px;border-radius:2px;background:{{ cat.color }};"></div>
<span style="font-size:13px;">{{ cat.name }}</span>
</div>
</td>
<td class="text-end mono text-income" style="font-size:13px;font-weight:500;padding-right:20px;">{{ cat.total | currency }}</td>
</tr>
{% endfor %}
<tr style="font-weight:700;background:#f8fafc;">
<td style="padding-left:20px;font-size:13px;">Total</td>
<td class="text-end mono text-income" style="font-size:13px;padding-right:20px;">{{ report.total_income | currency }}</td>
</tr>
{% else %}
<tr><td colspan="2" class="text-muted text-center py-3" style="font-size:13px;">No income recorded</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Expense by category -->
<div class="col-12 col-lg-6">
<div class="pcard p-0">
<div class="px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Expenses by Category</span>
</div>
<table class="pfm-table">
<thead><tr><th style="padding-left:20px;">Category</th><th class="text-end" style="padding-right:20px;">Total</th></tr></thead>
<tbody>
{% if report.expense_categories %}
{% for cat in report.expense_categories %}
<tr>
<td style="padding-left:20px;">
<div class="d-flex align-items-center gap-2">
<div style="width:8px;height:8px;border-radius:2px;background:{{ cat.color }};"></div>
<span style="font-size:13px;">{{ cat.name }}</span>
</div>
</td>
<td class="text-end mono text-expense" style="font-size:13px;font-weight:500;padding-right:20px;">{{ cat.total | currency }}</td>
</tr>
{% endfor %}
<tr style="font-weight:700;background:#f8fafc;">
<td style="padding-left:20px;font-size:13px;">Total</td>
<td class="text-end mono text-expense" style="font-size:13px;padding-right:20px;">{{ report.total_expense | currency }}</td>
</tr>
{% else %}
<tr><td colspan="2" class="text-muted text-center py-3" style="font-size:13px;">No expenses recorded</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- All income transactions for tax year -->
{% if report.income_transactions %}
<div class="pcard p-0 mt-3">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">All Income — {{ report.year }}</span>
<span class="text-muted" style="font-size:12px;">{{ report.income_transactions | length }} transactions</span>
</div>
<table class="pfm-table">
<thead><tr><th style="padding-left:20px;">Date</th><th>Description</th><th>Category</th><th class="text-end" style="padding-right:20px;">Amount</th></tr></thead>
<tbody>
{% for txn in report.income_transactions %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d, %Y') }}</td>
<td style="font-size:13px;">{{ txn.description }}</td>
<td style="font-size:12px;color:var(--muted);">{{ txn.category.name if txn.category else '—' }}</td>
<td class="text-end mono text-income" style="font-size:13px;font-weight:500;padding-right:20px;">+{{ txn.amount | currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="mt-3">
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Reports</a>
</div>
{% endblock %}
+23
View File
@@ -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}')