diff --git a/app/config.py b/app/config.py index d6ef60e..c7df588 100644 --- a/app/config.py +++ b/app/config.py @@ -30,6 +30,14 @@ class Config: TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem') TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '') + # Budget alert emails (optional — all four must be set to enable) + SMTP_HOST = os.environ.get('SMTP_HOST', '') + SMTP_PORT = int(os.environ.get('SMTP_PORT', 587)) + SMTP_USER = os.environ.get('SMTP_USER', '') + SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '') + ALERT_EMAIL = os.environ.get('ALERT_EMAIL', '') # recipient address + APP_URL = os.environ.get('APP_URL', 'https://pfm.ngodanguyen.tech') + # Application log file (rotating, shared by all app.* loggers) LOG_FILE_PATH = os.environ.get( 'LOG_FILE_PATH', diff --git a/app/routes/settings.py b/app/routes/settings.py index 6ab95d1..08a54d4 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -322,12 +322,36 @@ def import_confirm(): ALLOWED_RECEIPT_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'} +# Magic bytes → (canonical_extension, mime_type) +_MAGIC = [ + (b'\xff\xd8\xff', 'jpg', 'image/jpeg'), + (b'\x89PNG\r\n\x1a\n', 'png', 'image/png'), + (b'GIF87a', 'gif', 'image/gif'), + (b'GIF89a', 'gif', 'image/gif'), + (b'%PDF-', 'pdf', 'application/pdf'), + # WEBP: RIFF????WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP) +] + def _allowed_receipt(filename): return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in ALLOWED_RECEIPT_EXTENSIONS +def _check_magic(header_bytes): + """ + Inspect the first 12 bytes of a file and return (ext, mime) if recognised, + or (None, None) if the bytes don't match any allowed type. + """ + for magic, ext, mime in _MAGIC: + if header_bytes[:len(magic)] == magic: + return ext, mime + # WEBP special case: RIFF at 0, WEBP at 8 + if header_bytes[:4] == b'RIFF' and header_bytes[8:12] == b'WEBP': + return 'webp', 'image/webp' + return None, None + + @settings_bp.route('/receipt/upload/', methods=['POST']) @login_required def upload_receipt(txn_id): @@ -350,7 +374,15 @@ def upload_receipt(txn_id): flash('File too large. Maximum 10MB.', 'warning') return redirect(request.referrer or url_for('transactions.index')) - ext = f.filename.rsplit('.', 1)[1].lower() + # Validate actual file content via magic bytes (prevents renamed-file attacks) + header = f.read(12) + f.seek(0) + magic_ext, magic_mime = _check_magic(header) + if magic_ext is None: + flash('File content does not match an allowed type (PNG, JPG, GIF, PDF, WEBP).', 'warning') + return redirect(request.referrer or url_for('transactions.index')) + + ext = magic_ext # use the extension we detected, not what the user claimed unique_name = f'{uuid.uuid4().hex}.{ext}' upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads') os.makedirs(upload_dir, exist_ok=True) @@ -367,7 +399,7 @@ def upload_receipt(txn_id): filename=unique_name, original_filename=secure_filename(f.filename), file_size=file_size, - mime_type=f.content_type, + mime_type=magic_mime, # use validated type, not the browser-supplied header ) db.session.add(receipt) db.session.flush() diff --git a/app/routes/teller.py b/app/routes/teller.py index 5a699a7..12175a1 100644 --- a/app/routes/teller.py +++ b/app/routes/teller.py @@ -226,14 +226,30 @@ def sync_confirm(): parsed.append(r) imported, skipped = import_transactions(parsed, ta) - flash(f'Imported {imported} transaction(s). Skipped {skipped} duplicate(s).', 'success') + flash(f'Imported {imported} transaction(s) from {ta.account_name}. ' + f'Skipped {skipped} duplicate(s).', 'success') + + # Advance the sync queue if this came from a "Sync All" run + from flask import session as flask_session + queue = flask_session.pop('teller_sync_queue', []) + if queue: + next_id = queue[0] + flask_session['teller_sync_queue'] = queue[1:] + flash(f'{len(queue)} account(s) remaining in sync queue.', 'info') + return redirect(url_for('teller.sync_preview_view', teller_account_id=next_id)) + return redirect(url_for('transactions.index')) @teller_bp.route('/sync/all', methods=['POST']) @login_required def sync_all(): - """Sync all mapped accounts — redirects to first account's preview.""" + """ + Queue all mapped accounts for sequential sync. + Stores the full list in session, then redirects to the first account's preview. + After each confirm the queue advances automatically. + """ + from flask import session as flask_session enrollments = TellerEnrollment.query.filter_by(is_active=True).all() mapped = [] for e in enrollments: @@ -245,7 +261,8 @@ def sync_all(): flash('No accounts mapped for sync.', 'warning') return redirect(url_for('teller.index')) - # For simplicity, sync first account; user can chain through others + # Store the full queue; first item will be previewed immediately + flask_session['teller_sync_queue'] = [ta.id for ta in mapped[1:]] return redirect(url_for('teller.sync_preview_view', teller_account_id=mapped[0].id)) diff --git a/app/routes/transactions.py b/app/routes/transactions.py index d067864..ce1995e 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -140,6 +140,9 @@ def new(): db.session.commit() calc_balance(txn.account_id) flash(f'{"Income" if txn_type == "income" else "Expense"} added.', 'success') + if txn_type == 'expense': + from app.services.alert_service import check_and_flash_budget_alerts + check_and_flash_budget_alerts(flash) return redirect(url_for('transactions.index', tab=txn_type)) return render_template('transactions/form.html', @@ -174,6 +177,9 @@ def edit(id): calc_balance(old_account_id) calc_balance(txn.account_id) flash('Transaction updated.', 'success') + if txn.transaction_type == 'expense': + from app.services.alert_service import check_and_flash_budget_alerts + check_and_flash_budget_alerts(flash) return redirect(url_for('transactions.index', tab=txn.transaction_type)) return render_template('transactions/form.html', diff --git a/app/services/alert_service.py b/app/services/alert_service.py new file mode 100644 index 0000000..90e2c5a --- /dev/null +++ b/app/services/alert_service.py @@ -0,0 +1,136 @@ +""" +Alert Service — budget threshold alerts triggered after expense transactions. + +Checks all budgeted categories for the current month and returns alert dicts +for any that just crossed 80% or 100%. Also sends an email if SMTP is configured. + +Usage (in routes after db.session.commit()): + from app.services.alert_service import check_and_flash_budget_alerts + check_and_flash_budget_alerts(flash_fn) +""" + +import logging +import smtplib +import ssl +from datetime import date +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from flask import current_app + +log = logging.getLogger(__name__) + + +def _smtp_configured(): + cfg = current_app.config + return all([ + cfg.get('SMTP_HOST'), + cfg.get('SMTP_USER'), + cfg.get('SMTP_PASSWORD'), + cfg.get('ALERT_EMAIL'), + ]) + + +def _send_email(subject, body_html): + cfg = current_app.config + host = cfg['SMTP_HOST'] + port = int(cfg.get('SMTP_PORT', 587)) + user = cfg['SMTP_USER'] + password = cfg['SMTP_PASSWORD'] + to_addr = cfg['ALERT_EMAIL'] + + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = user + msg['To'] = to_addr + msg.attach(MIMEText(body_html, 'html')) + + try: + context = ssl.create_default_context() + with smtplib.SMTP(host, port) as srv: + srv.ehlo() + srv.starttls(context=context) + srv.login(user, password) + srv.sendmail(user, to_addr, msg.as_string()) + log.info('[alert] email sent: %s → %s', subject, to_addr) + except Exception as exc: + log.error('[alert] email failed: %s', exc, exc_info=True) + + +def get_budget_alerts(month_str=None): + """ + Return list of alert dicts for budget categories at or over threshold. + + Each dict: {category, spent, limit, pct, level} + level: 'over' (≥100%) | 'warning' (≥80%) + + Only returns categories that have a budget set. + """ + from app.services.budget_service import get_budget_summary + + if month_str is None: + today = date.today() + month_str = today.strftime('%Y-%m') + + summary = get_budget_summary(month_str) + alerts = [] + for item in summary: + if not item['has_budget'] or item['pct'] is None: + continue + pct = item['pct'] + if pct >= 100: + alerts.append({**item, 'level': 'over'}) + elif pct >= 80: + alerts.append({**item, 'level': 'warning'}) + + return alerts + + +def check_and_flash_budget_alerts(flash_fn): + """ + Check current month's budgets and flash any threshold alerts. + Optionally sends an email summary if SMTP is configured. + + Call this after committing an expense transaction. + + flash_fn: Flask's flash() function (passed in to avoid circular imports) + """ + try: + alerts = get_budget_alerts() + except Exception as exc: + log.error('[alert] check failed: %s', exc, exc_info=True) + return + + if not alerts: + return + + email_lines = [] + for a in alerts: + cat_name = a['category'].name if a['category'] else 'Unknown' + pct = a['pct'] + spent = a['spent'] + limit = a['limit'] + symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$') + + if a['level'] == 'over': + msg = (f'Budget exceeded: {cat_name} — ' + f'{symbol}{spent:,.2f} spent of {symbol}{limit:,.2f} limit ({pct:.0f}%)') + flash_fn(msg, 'danger') + else: + msg = (f'Budget warning: {cat_name} — ' + f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)') + flash_fn(msg, 'warning') + + email_lines.append( + f'
  • {cat_name}: {symbol}{spent:,.2f} / {symbol}{limit:,.2f} ' + f'({pct:.0f}%) — {"EXCEEDED" if a["level"]=="over" else "Warning"}
  • ' + ) + + if _smtp_configured() and email_lines: + today = date.today().strftime('%B %Y') + body = ( + f'

    PFM Budget Alert — {today}

    ' + f'

    The following budget categories need your attention:

    ' + f'' + f'

    View Budgets →

    ' + ) + _send_email(f'PFM Budget Alert — {today}', body) diff --git a/app/templates/base.html b/app/templates/base.html index 6b59cfe..40ee54e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -161,7 +161,16 @@ #sidebar { transform: translateX(-100%); width: var(--sidebar-width) !important; } #sidebar.mob-open { transform: translateX(0); } #topbar, #sidebar.collapsed ~ #topbar { left: 0 !important; } - #main, #sidebar.collapsed ~ #main { margin-left: 0 !important; } + #main, #sidebar.collapsed ~ #main { margin-left: 0 !important; padding: 14px; } + .pcard { padding: 14px; } + .stat-card { padding: 14px 16px; } + .stat-card .stat-value { font-size: 20px; } + /* Tables: scroll horizontally on small screens. + Targets both manual .table-wrap and the common .pcard.p-0 pattern. */ + .table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; } + .pfm-table { min-width: 560px; } + /* Hide low-priority columns */ + .d-mob-none { display: none !important; } } .sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; } .sb-overlay.on { display: block; } diff --git a/app/templates/transactions/index.html b/app/templates/transactions/index.html index f28d179..af1ffbe 100644 --- a/app/templates/transactions/index.html +++ b/app/templates/transactions/index.html @@ -60,6 +60,7 @@ +{% set has_filter = search or category_id or account_id or date_from or date_to %}
    {% if transactions %} @@ -120,10 +121,22 @@ {% endif %} {% else %} -
    - -

    No {{ tab }} transactions found.

    - Add {{ tab | title }} +
    + {% if has_filter %} + +

    No matching transactions

    +

    Try adjusting your filters or search term.

    + + Clear filters + + {% else %} + +

    No {{ tab }} transactions yet

    +

    Record your first {{ tab }} to start tracking.

    + + Add {{ tab | title }} + + {% endif %}
    {% endif %}