diff --git a/app/models/budget.py b/app/models/budget.py index cb6e82c..cc38c96 100644 --- a/app/models/budget.py +++ b/app/models/budget.py @@ -11,6 +11,8 @@ class Budget(db.Model): limit_amount = db.Column(db.Numeric(15, 2), nullable=False) rollover_enabled = db.Column(db.Boolean, default=False) rollover_amount = db.Column(db.Numeric(15, 2), default=0.00) + alert_sent_80 = db.Column(db.Boolean, default=False, nullable=False) + alert_sent_100 = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) category = db.relationship('Category') diff --git a/app/models/user.py b/app/models/user.py index 97dfc24..b1de3f1 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -18,6 +18,7 @@ class User(UserMixin, db.Model): groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile') totp_secret = db.Column(db.String(64), nullable=True) totp_enabled = db.Column(db.Boolean, default=False, nullable=False) + budget_alerts_enabled = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) last_login = db.Column(db.DateTime, nullable=True) diff --git a/app/routes/settings.py b/app/routes/settings.py index 9988c3b..4b46167 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -68,6 +68,7 @@ class ProfileForm(FlaskForm): ]) currency = SelectField('Currency', choices=CURRENCIES) groq_model = SelectField('AI Model', choices=GROQ_MODELS) + budget_alerts_enabled = BooleanField('Email me when a budget category reaches 80% or 100%') submit = SubmitField('Save Profile') @@ -156,10 +157,28 @@ def profile(): current_user.currency = form.currency.data current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$') current_user.groq_model = form.groq_model.data + current_user.budget_alerts_enabled = form.budget_alerts_enabled.data db.session.commit() flash('Profile updated.', 'success') return redirect(url_for('settings.profile')) - return render_template('settings/profile.html', form=form) + smtp_ok = all([ + current_app.config.get('SMTP_HOST'), + current_app.config.get('SMTP_USER'), + current_app.config.get('SMTP_PASSWORD'), + current_app.config.get('ALERT_EMAIL'), + ]) + return render_template('settings/profile.html', form=form, + smtp_ok=smtp_ok, + alert_email=current_app.config.get('ALERT_EMAIL', '')) + + +@settings_bp.route('/test-email', methods=['POST']) +@login_required +def test_email(): + from app.services.alert_service import send_test_email + ok, msg = send_test_email() + flash(msg, 'success' if ok else 'danger') + return redirect(url_for('settings.profile')) @settings_bp.route('/password', methods=['GET', 'POST']) diff --git a/app/services/alert_service.py b/app/services/alert_service.py index 90e2c5a..d2eda35 100644 --- a/app/services/alert_service.py +++ b/app/services/alert_service.py @@ -1,8 +1,9 @@ """ 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. +Checks budgeted categories and flashes/emails when spending crosses 80% or 100%. +Dedup: alert_sent_80 / alert_sent_100 flags on each Budget row prevent repeat +emails within the same month. Flags default to False on new Budget rows. Usage (in routes after db.session.commit()): from app.services.alert_service import check_and_flash_budget_alerts @@ -17,7 +18,9 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from flask import current_app -log = logging.getLogger(__name__) +from app.extensions import db + +log = logging.getLogger('app.alert_service') def _smtp_configured(): @@ -31,12 +34,12 @@ def _smtp_configured(): 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'] + cfg = current_app.config + host = cfg['SMTP_HOST'] + port = int(cfg.get('SMTP_PORT', 587)) + user = cfg['SMTP_USER'] + pw = cfg['SMTP_PASSWORD'] + to_addr = cfg['ALERT_EMAIL'] msg = MIMEMultipart('alternative') msg['Subject'] = subject @@ -45,31 +48,47 @@ def _send_email(subject, body_html): msg.attach(MIMEText(body_html, 'html')) try: - context = ssl.create_default_context() + ctx = ssl.create_default_context() with smtplib.SMTP(host, port) as srv: srv.ehlo() - srv.starttls(context=context) - srv.login(user, password) + srv.starttls(context=ctx) + srv.login(user, pw) srv.sendmail(user, to_addr, msg.as_string()) log.info('[alert] email sent: %s → %s', subject, to_addr) + return True except Exception as exc: log.error('[alert] email failed: %s', exc, exc_info=True) + return False + + +def send_test_email(): + """Send a test email to verify SMTP config. Returns (ok: bool, message: str).""" + if not _smtp_configured(): + return False, ('SMTP not configured. ' + 'Set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, and ALERT_EMAIL in .env.') + to_addr = current_app.config['ALERT_EMAIL'] + body = ( + '
This is a test email from your Personal Finance Management app.
' + 'Budget alerts are configured correctly.
' + ) + ok = _send_email('PFM — Test Email', body) + if ok: + return True, f'Test email sent to {to_addr}.' + return False, 'SMTP error — check server logs for details.' def get_budget_alerts(month_str=None): """ Return list of alert dicts for budget categories at or over threshold. + Does NOT check dedup flags — use for display purposes only. 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') + month_str = date.today().strftime('%Y-%m') summary = get_budget_summary(month_str) alerts = [] @@ -81,56 +100,117 @@ def get_budget_alerts(month_str=None): 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. + Check current month's budgets. For each category that newly crossed 80% or + 100%, flash a message and (if SMTP configured and user has alerts enabled) + send one email. Dedup flags on the Budget row prevent repeat sends. Call this after committing an expense transaction. - - flash_fn: Flask's flash() function (passed in to avoid circular imports) + flash_fn: Flask's flash() (passed in to avoid circular imports at module level) """ + from app.models.budget import Budget + from app.models.user import User + from app.services.budget_service import get_month_spending + + user = User.query.first() + alerts_enabled = user.budget_alerts_enabled if user else False + symbol = (user.currency_symbol or '$') if user else '$' + + month_str = date.today().strftime('%Y-%m') + try: - alerts = get_budget_alerts() + budgets = Budget.query.filter_by(month=month_str).all() except Exception as exc: - log.error('[alert] check failed: %s', exc, exc_info=True) + log.error('[alert] failed to query budgets: %s', exc) return - if not alerts: - return + dirty_budgets = [] - 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', '$') + for b in budgets: + try: + limit = float(b.limit_amount) + float(b.rollover_amount or 0) + if limit <= 0: + continue + spent = get_month_spending(b.category_id, month_str) + pct = spent / limit * 100 + cat = b.category + cat_name = cat.name if cat else 'Unknown' - 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') + # 100% threshold — only fire if not already sent this month + if pct >= 100 and not b.alert_sent_100: + msg = (f'Budget exceeded: {cat_name} — ' + f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}%)') + flash_fn(msg, 'danger') + if alerts_enabled and _smtp_configured(): + _send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold=100) + b.alert_sent_100 = True + dirty_budgets.append(b) - email_lines.append( - f'The following budget categories need your attention:
' - f'+ Your {cat_name} budget has reached + {pct:.0f}%. +
+| Spent | +{symbol}{spent:,.2f} | +
| Budget limit | +{symbol}{limit:,.2f} | +
| Remaining | +{symbol}{remaining:,.2f} | +
+ Disable alerts in Settings → Profile. +
+