05/31 Phase 6
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user