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
+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