""" 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""" {icon} {cat}
${amount:.2f} """ insight_rows = '' for ins in insights: insight_rows += f""" {ins['icon']} {ins['title']} {ins['stat']} {ins['detail']}""" merchant_rows = '' for m in top_merchants: merchant_rows += f""" {m['name']} {m['count']}ร— ${m['total']:.2f} """ return f"""
American Express
Statement Summary
{tx_count} transactions analysed
Total Spend
${total_spend:.2f}
Payments
${total_payments:.2f}
Transactions
{tx_count}
Spending by Category
{cat_rows}
Key Insights
{insight_rows}
Top Merchants
{merchant_rows}
Generated by AMEX Statement Analyser ยท Your data never leaves your device
""" 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)