198 lines
7.3 KiB
Python
198 lines
7.3 KiB
Python
"""
|
|
Budget Alerts API
|
|
Provides budget status, alerts, and notification management
|
|
Security: All queries filtered by user_id
|
|
"""
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_login import login_required, current_user
|
|
from app.models import Category, Expense
|
|
from app import db
|
|
from datetime import datetime, timedelta
|
|
from sqlalchemy import func
|
|
|
|
bp = Blueprint('budget', __name__, url_prefix='/api/budget')
|
|
|
|
|
|
@bp.route('/status', methods=['GET'])
|
|
@login_required
|
|
def get_budget_status():
|
|
"""
|
|
Get budget status for all user categories and overall monthly budget
|
|
Security: Only returns current user's data
|
|
|
|
Returns:
|
|
- overall: Total spending vs monthly budget
|
|
- categories: Per-category budget status
|
|
- alerts: Active budget alerts
|
|
"""
|
|
# Get current month date range
|
|
now = datetime.utcnow()
|
|
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
# Calculate overall monthly spending - Security: filter by user_id
|
|
total_spent = db.session.query(func.sum(Expense.amount)).filter(
|
|
Expense.user_id == current_user.id,
|
|
Expense.date >= start_of_month
|
|
).scalar() or 0.0
|
|
|
|
overall_status = {
|
|
'spent': float(total_spent),
|
|
'budget': current_user.monthly_budget or 0,
|
|
'remaining': (current_user.monthly_budget or 0) - float(total_spent),
|
|
'percentage': 0 if not current_user.monthly_budget else round((float(total_spent) / current_user.monthly_budget) * 100, 1),
|
|
'alert_level': 'none'
|
|
}
|
|
|
|
# Determine overall alert level
|
|
if current_user.monthly_budget and current_user.monthly_budget > 0:
|
|
if overall_status['percentage'] >= 100:
|
|
overall_status['alert_level'] = 'exceeded'
|
|
elif overall_status['percentage'] >= 90:
|
|
overall_status['alert_level'] = 'danger'
|
|
elif overall_status['percentage'] >= 80:
|
|
overall_status['alert_level'] = 'warning'
|
|
|
|
# Get category budgets - Security: filter by user_id
|
|
categories = Category.query.filter_by(user_id=current_user.id).all()
|
|
category_statuses = []
|
|
active_alerts = []
|
|
|
|
for category in categories:
|
|
if category.monthly_budget and category.monthly_budget > 0:
|
|
status = category.get_budget_status()
|
|
category_statuses.append({
|
|
'category_id': category.id,
|
|
'category_name': category.name,
|
|
'category_color': category.color,
|
|
'category_icon': category.icon,
|
|
**status
|
|
})
|
|
|
|
# Add to alerts if over threshold
|
|
if status['alert_level'] in ['warning', 'danger', 'exceeded']:
|
|
active_alerts.append({
|
|
'category_id': category.id,
|
|
'category_name': category.name,
|
|
'category_color': category.color,
|
|
'alert_level': status['alert_level'],
|
|
'percentage': status['percentage'],
|
|
'spent': status['spent'],
|
|
'budget': status['budget'],
|
|
'remaining': status['remaining']
|
|
})
|
|
|
|
# Sort alerts by severity
|
|
alert_order = {'exceeded': 0, 'danger': 1, 'warning': 2}
|
|
active_alerts.sort(key=lambda x: (alert_order[x['alert_level']], -x['percentage']))
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'overall': overall_status,
|
|
'categories': category_statuses,
|
|
'alerts': active_alerts,
|
|
'alert_count': len(active_alerts)
|
|
})
|
|
|
|
|
|
@bp.route('/weekly-summary', methods=['GET'])
|
|
@login_required
|
|
def get_weekly_summary():
|
|
"""
|
|
Get weekly spending summary for notification
|
|
Security: Only returns current user's data
|
|
|
|
Returns:
|
|
- week_total: Total spent this week
|
|
- daily_average: Average per day
|
|
- top_category: Highest spending category
|
|
- comparison: vs previous week
|
|
"""
|
|
now = datetime.utcnow()
|
|
week_start = now - timedelta(days=now.weekday()) # Monday
|
|
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
prev_week_start = week_start - timedelta(days=7)
|
|
|
|
# Current week spending - Security: filter by user_id
|
|
current_week_expenses = Expense.query.filter(
|
|
Expense.user_id == current_user.id,
|
|
Expense.date >= week_start
|
|
).all()
|
|
|
|
week_total = sum(e.amount for e in current_week_expenses)
|
|
daily_average = week_total / max(1, (now - week_start).days + 1)
|
|
|
|
# Previous week for comparison
|
|
prev_week_expenses = Expense.query.filter(
|
|
Expense.user_id == current_user.id,
|
|
Expense.date >= prev_week_start,
|
|
Expense.date < week_start
|
|
).all()
|
|
|
|
prev_week_total = sum(e.amount for e in prev_week_expenses)
|
|
change_percent = 0
|
|
if prev_week_total > 0:
|
|
change_percent = ((week_total - prev_week_total) / prev_week_total) * 100
|
|
|
|
# Find top category
|
|
category_totals = {}
|
|
for expense in current_week_expenses:
|
|
if expense.category:
|
|
category_totals[expense.category.name] = category_totals.get(expense.category.name, 0) + expense.amount
|
|
|
|
top_category = max(category_totals.items(), key=lambda x: x[1]) if category_totals else (None, 0)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'week_total': float(week_total),
|
|
'daily_average': float(daily_average),
|
|
'previous_week_total': float(prev_week_total),
|
|
'change_percent': round(change_percent, 1),
|
|
'top_category': top_category[0] if top_category[0] else 'None',
|
|
'top_category_amount': float(top_category[1]),
|
|
'expense_count': len(current_week_expenses),
|
|
'week_start': week_start.isoformat(),
|
|
'currency': current_user.currency
|
|
})
|
|
|
|
|
|
@bp.route('/category/<int:category_id>/budget', methods=['PUT'])
|
|
@login_required
|
|
def update_category_budget(category_id):
|
|
"""
|
|
Update budget settings for a category
|
|
Security: Verify category belongs to current user
|
|
"""
|
|
# Security check: ensure category belongs to current user
|
|
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
|
|
|
|
if not category:
|
|
return jsonify({'success': False, 'message': 'Category not found'}), 404
|
|
|
|
data = request.get_json()
|
|
|
|
try:
|
|
if 'monthly_budget' in data:
|
|
budget = float(data['monthly_budget']) if data['monthly_budget'] else None
|
|
if budget is not None and budget < 0:
|
|
return jsonify({'success': False, 'message': 'Budget cannot be negative'}), 400
|
|
category.monthly_budget = budget
|
|
|
|
if 'budget_alert_threshold' in data:
|
|
threshold = float(data['budget_alert_threshold'])
|
|
if threshold < 0.5 or threshold > 2.0:
|
|
return jsonify({'success': False, 'message': 'Threshold must be between 0.5 (50%) and 2.0 (200%)'}), 400
|
|
category.budget_alert_threshold = threshold
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Budget updated successfully',
|
|
'category': category.to_dict()
|
|
})
|
|
except ValueError as e:
|
|
return jsonify({'success': False, 'message': f'Invalid data: {str(e)}'}), 400
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'message': f'Error updating budget: {str(e)}'}), 500
|