05/31 Phase 6
This commit is contained in:
@@ -27,6 +27,7 @@ def create_app(config_name=None):
|
|||||||
from app.routes.goals import goals_bp
|
from app.routes.goals import goals_bp
|
||||||
from app.routes.investments import investments_bp
|
from app.routes.investments import investments_bp
|
||||||
from app.routes.ai import ai_bp
|
from app.routes.ai import ai_bp
|
||||||
|
from app.routes.reports import reports_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
@@ -37,6 +38,7 @@ def create_app(config_name=None):
|
|||||||
app.register_blueprint(goals_bp)
|
app.register_blueprint(goals_bp)
|
||||||
app.register_blueprint(investments_bp)
|
app.register_blueprint(investments_bp)
|
||||||
app.register_blueprint(ai_bp)
|
app.register_blueprint(ai_bp)
|
||||||
|
app.register_blueprint(reports_bp)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
|||||||
@@ -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'))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
<a href="{{ url_for('investments.index') }}" class="sb-link {% if request.blueprint == 'investments' %}active{% endif %}">
|
<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>
|
<i class="bi bi-graph-up-arrow"></i><span class="lt">Investments</span>
|
||||||
</a>
|
</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>
|
<i class="bi bi-file-earmark-bar-graph"></i><span class="lt">Reports</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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}')
|
||||||
Reference in New Issue
Block a user