Files
amexpal/app.py
T

239 lines
9.3 KiB
Python
Raw Normal View History

"""
AMEX Statement Analyser — Flask API
"""
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)