Files
2026-04-15 09:39:48 +12:00

235 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import smtplib
import tempfile
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from dotenv import load_dotenv
from flask import Flask, jsonify, request
from flask_cors import CORS
from amex_analyser import analyse, generate_insights, CATEGORY_ICONS
from amex_parser import parse_statement
load_dotenv()
app = Flask(__name__, static_folder='frontend/dist', static_url_path='')
CORS(app, resources={r'/api/*': {'origins': '*'}})
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50 MB
def allowed(filename: str) -> bool:
return filename.lower().endswith('.pdf')
@app.route('/')
def index():
return app.send_static_file('index.html')
@app.route('/api/analyse', methods=['POST'])
def analyse_statement():
files = request.files.getlist('files')
if not files or all(f.filename == '' for f in files):
# Fallback: single-file field name
single = request.files.get('file')
if single:
files = [single]
else:
return jsonify({'error': 'No files provided'}), 400
all_enriched = []
tmp_paths = []
try:
for f in files:
if not f.filename or not allowed(f.filename):
continue
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
f.save(tmp.name)
tmp_paths.append(tmp.name)
txns = parse_statement(tmp_paths[-1])
for tx in txns:
from amex_analyser import categorise, normalise
all_enriched.append({
'date': tx.date,
'description': normalise(tx.description), # strip WINDCAVE* etc.
'amount': tx.amount,
'is_credit': tx.is_credit,
'category': categorise(tx.description),
})
if not all_enriched:
return jsonify({'error': 'No transactions found. Check the PDF is an AMEX NZ statement.'}), 422
result = analyse(enriched_transactions=all_enriched)
return jsonify(result)
except Exception as e:
return jsonify({'error': f'Failed to parse statement: {str(e)}'}), 500
finally:
for p in tmp_paths:
try:
os.unlink(p)
except Exception:
pass
@app.route('/api/email', methods=['POST'])
def send_email():
data = request.get_json()
to_email = (data or {}).get('to', '').strip()
result = (data or {}).get('result')
if not to_email:
return jsonify({'error': 'No email address provided'}), 400
smtp_host = os.getenv('SMTP_HOST', '')
smtp_port = int(os.getenv('SMTP_PORT', 587))
smtp_user = os.getenv('SMTP_USER', '')
smtp_pass = os.getenv('SMTP_PASS', '')
from_addr = os.getenv('EMAIL_FROM', smtp_user)
if not all([smtp_host, smtp_user, smtp_pass]):
return jsonify({
'error': 'Email not configured. Create a .env file — see .env.example for SMTP settings.'
}), 503
try:
html = _build_email_html(result)
msg = MIMEMultipart('alternative')
msg['Subject'] = _email_subject(result)
msg['From'] = from_addr
msg['To'] = to_email
msg.attach(MIMEText(html, 'html'))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
server.starttls()
server.login(smtp_user, smtp_pass)
server.sendmail(from_addr, [to_email], msg.as_string())
return jsonify({'success': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
def _email_subject(result: dict) -> str:
txns = result.get('transactions', [])
if txns:
dates = sorted(t['date'] for t in txns if not t['is_credit'])
if dates:
from datetime import datetime
try:
d = datetime.strptime(dates[-1], '%d.%m.%y')
return f"AMEX Statement Summary — {d.strftime('%B %Y')}"
except Exception:
pass
return 'AMEX Statement Summary'
def _build_email_html(result: dict) -> str:
total_spend = result.get('total_spend', 0)
total_payments = result.get('total_payments', 0)
tx_count = result.get('transaction_count', 0)
by_category = result.get('by_category', {})
insights = result.get('insights', [])
top_merchants = result.get('top_merchants', [])[:5]
cat_rows = ''
for cat, amount in by_category.items():
if cat == 'Payments':
continue
pct = int(amount / total_spend * 100) if total_spend else 0
icon = CATEGORY_ICONS.get(cat, '📦')
cat_rows += f"""
<tr>
<td style="padding:6px 0;font-size:13px;color:#444;width:180px">{icon} {cat}</td>
<td style="padding:6px 8px">
<div style="background:#f0f2f7;border-radius:4px;height:8px;width:100%">
<div style="background:#006fcf;border-radius:4px;height:8px;width:{pct}%"></div>
</div>
</td>
<td style="padding:6px 0;font-size:13px;font-weight:600;color:#1a1a2e;text-align:right;white-space:nowrap">${amount:.2f}</td>
</tr>"""
insight_rows = ''
for ins in insights:
insight_rows += f"""
<tr>
<td style="padding:6px 12px 6px 0;font-size:13px;color:#555">{ins['icon']} {ins['title']}</td>
<td style="padding:6px 0;font-size:13px;font-weight:700;color:#1a1a2e;text-align:right">{ins['stat']}</td>
</tr>
<tr><td colspan="2" style="padding:0 0 8px;font-size:12px;color:#888">{ins['detail']}</td></tr>"""
merchant_rows = ''
for m in top_merchants:
merchant_rows += f"""
<tr>
<td style="padding:5px 12px 5px 0;font-size:13px;color:#444">{m['name']}</td>
<td style="padding:5px 0;font-size:12px;color:#aaa;text-align:center">{m['count']}×</td>
<td style="padding:5px 0;font-size:13px;font-weight:600;color:#1a1a2e;text-align:right">${m['total']:.2f}</td>
</tr>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:20px;background:#f5f6fa;font-family:-apple-system,Arial,sans-serif">
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 2px 20px rgba(0,0,0,0.08)">
<div style="background:linear-gradient(135deg,#006fcf,#0051a0);color:#fff;padding:28px 32px">
<div style="font-size:11px;letter-spacing:0.1em;opacity:0.75;text-transform:uppercase;margin-bottom:6px">American Express</div>
<div style="font-size:24px;font-weight:700;margin-bottom:4px">Statement Summary</div>
<div style="font-size:13px;opacity:0.85">{tx_count} transactions analysed</div>
</div>
<div style="padding:24px 32px">
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px">
<tr>
<td style="text-align:center;background:#f0f7ff;border-radius:10px;padding:16px;width:33%">
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:0.05em">Total Spend</div>
<div style="font-size:22px;font-weight:700;color:#006fcf;margin-top:4px">${total_spend:.2f}</div>
</td>
<td style="width:12px"></td>
<td style="text-align:center;background:#f0faf4;border-radius:10px;padding:16px;width:33%">
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:0.05em">Payments</div>
<div style="font-size:22px;font-weight:700;color:#27ae60;margin-top:4px">${total_payments:.2f}</div>
</td>
<td style="width:12px"></td>
<td style="text-align:center;background:#fff8f0;border-radius:10px;padding:16px;width:33%">
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:0.05em">Transactions</div>
<div style="font-size:22px;font-weight:700;color:#e67e22;margin-top:4px">{tx_count}</div>
</td>
</tr>
</table>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:#aaa;margin-bottom:12px">Spending by Category</div>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px">
{cat_rows}
</table>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:#aaa;margin-bottom:12px">Key Insights</div>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px">
{insight_rows}
</table>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:#aaa;margin-bottom:12px">Top Merchants</div>
<table width="100%" cellpadding="0" cellspacing="0">
{merchant_rows}
</table>
</div>
<div style="padding:16px 32px;border-top:1px solid #f0f2f7;font-size:11px;color:#ccc;text-align:center">
Generated by AMEX Statement Analyser · Your data never leaves your device
</div>
</div>
</body>
</html>"""
if __name__ == '__main__':
# use_reloader=False: prevents Werkzeug spawning a second process (avoids duplicate instances)
# threaded=True: each request gets its own thread so PDF parsing doesn't block the browser
app.run(debug=True, port=5000, threaded=True, use_reloader=False)