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