235 lines
9.3 KiB
Python
235 lines
9.3 KiB
Python
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)
|