438 lines
16 KiB
Python
438 lines
16 KiB
Python
from flask import Blueprint, request, jsonify, current_app
|
|
from flask_login import login_required, current_user
|
|
from app import db
|
|
from app.models import RecurringExpense, Expense, Category
|
|
from datetime import datetime, timedelta
|
|
from dateutil.relativedelta import relativedelta
|
|
from collections import defaultdict
|
|
import re
|
|
|
|
bp = Blueprint('recurring', __name__, url_prefix='/api/recurring')
|
|
|
|
|
|
def calculate_next_due_date(frequency, day_of_period=None, from_date=None):
|
|
"""Calculate next due date based on frequency"""
|
|
base_date = from_date or datetime.utcnow()
|
|
|
|
if frequency == 'daily':
|
|
return base_date + timedelta(days=1)
|
|
elif frequency == 'weekly':
|
|
# day_of_period is day of week (0=Monday, 6=Sunday)
|
|
target_day = day_of_period if day_of_period is not None else base_date.weekday()
|
|
days_ahead = target_day - base_date.weekday()
|
|
if days_ahead <= 0:
|
|
days_ahead += 7
|
|
return base_date + timedelta(days=days_ahead)
|
|
elif frequency == 'monthly':
|
|
# day_of_period is day of month (1-31)
|
|
target_day = day_of_period if day_of_period is not None else base_date.day
|
|
next_month = base_date + relativedelta(months=1)
|
|
try:
|
|
return next_month.replace(day=min(target_day, 28)) # Safe day
|
|
except ValueError:
|
|
# Handle months with fewer days
|
|
return next_month.replace(day=28)
|
|
elif frequency == 'yearly':
|
|
return base_date + relativedelta(years=1)
|
|
else:
|
|
return base_date + timedelta(days=30)
|
|
|
|
|
|
@bp.route('/', methods=['GET'])
|
|
@login_required
|
|
def get_recurring_expenses():
|
|
"""Get all recurring expenses for current user"""
|
|
# Security: Filter by user_id
|
|
recurring = RecurringExpense.query.filter_by(user_id=current_user.id).order_by(
|
|
RecurringExpense.is_active.desc(),
|
|
RecurringExpense.next_due_date.asc()
|
|
).all()
|
|
|
|
return jsonify({
|
|
'recurring_expenses': [r.to_dict() for r in recurring]
|
|
})
|
|
|
|
|
|
@bp.route('/', methods=['POST'])
|
|
@login_required
|
|
def create_recurring_expense():
|
|
"""Create a new recurring expense"""
|
|
data = request.get_json()
|
|
|
|
# Validate required fields
|
|
if not data or not data.get('name') or not data.get('amount') or not data.get('category_id') or not data.get('frequency'):
|
|
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
|
|
|
|
# Security: Verify category belongs to current user
|
|
category = Category.query.filter_by(id=int(data.get('category_id')), user_id=current_user.id).first()
|
|
if not category:
|
|
return jsonify({'success': False, 'message': 'Invalid category'}), 400
|
|
|
|
# Validate frequency
|
|
valid_frequencies = ['daily', 'weekly', 'monthly', 'yearly']
|
|
frequency = data.get('frequency')
|
|
if frequency not in valid_frequencies:
|
|
return jsonify({'success': False, 'message': 'Invalid frequency'}), 400
|
|
|
|
# Calculate next due date
|
|
day_of_period = data.get('day_of_period')
|
|
next_due_date = data.get('next_due_date')
|
|
|
|
if next_due_date:
|
|
next_due_date = datetime.fromisoformat(next_due_date)
|
|
else:
|
|
next_due_date = calculate_next_due_date(frequency, day_of_period)
|
|
|
|
# Create recurring expense
|
|
recurring = RecurringExpense(
|
|
name=data.get('name'),
|
|
amount=float(data.get('amount')),
|
|
currency=data.get('currency', current_user.currency),
|
|
category_id=int(data.get('category_id')),
|
|
frequency=frequency,
|
|
day_of_period=day_of_period,
|
|
next_due_date=next_due_date,
|
|
auto_create=data.get('auto_create', False),
|
|
is_active=data.get('is_active', True),
|
|
notes=data.get('notes'),
|
|
detected=False, # Manually created
|
|
user_id=current_user.id
|
|
)
|
|
|
|
db.session.add(recurring)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'recurring_expense': recurring.to_dict()
|
|
}), 201
|
|
|
|
|
|
@bp.route('/<int:recurring_id>', methods=['PUT'])
|
|
@login_required
|
|
def update_recurring_expense(recurring_id):
|
|
"""Update a recurring expense"""
|
|
# Security: Filter by user_id
|
|
recurring = RecurringExpense.query.filter_by(id=recurring_id, user_id=current_user.id).first()
|
|
|
|
if not recurring:
|
|
return jsonify({'success': False, 'message': 'Recurring expense not found'}), 404
|
|
|
|
data = request.get_json()
|
|
|
|
# Update fields
|
|
if data.get('name'):
|
|
recurring.name = data.get('name')
|
|
if data.get('amount'):
|
|
recurring.amount = float(data.get('amount'))
|
|
if data.get('currency'):
|
|
recurring.currency = data.get('currency')
|
|
if data.get('category_id'):
|
|
# Security: Verify category belongs to current user
|
|
category = Category.query.filter_by(id=int(data.get('category_id')), user_id=current_user.id).first()
|
|
if not category:
|
|
return jsonify({'success': False, 'message': 'Invalid category'}), 400
|
|
recurring.category_id = int(data.get('category_id'))
|
|
if data.get('frequency'):
|
|
valid_frequencies = ['daily', 'weekly', 'monthly', 'yearly']
|
|
if data.get('frequency') not in valid_frequencies:
|
|
return jsonify({'success': False, 'message': 'Invalid frequency'}), 400
|
|
recurring.frequency = data.get('frequency')
|
|
if 'day_of_period' in data:
|
|
recurring.day_of_period = data.get('day_of_period')
|
|
if data.get('next_due_date'):
|
|
recurring.next_due_date = datetime.fromisoformat(data.get('next_due_date'))
|
|
if 'auto_create' in data:
|
|
recurring.auto_create = data.get('auto_create')
|
|
if 'is_active' in data:
|
|
recurring.is_active = data.get('is_active')
|
|
if 'notes' in data:
|
|
recurring.notes = data.get('notes')
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'recurring_expense': recurring.to_dict()
|
|
})
|
|
|
|
|
|
@bp.route('/<int:recurring_id>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_recurring_expense(recurring_id):
|
|
"""Delete a recurring expense"""
|
|
# Security: Filter by user_id
|
|
recurring = RecurringExpense.query.filter_by(id=recurring_id, user_id=current_user.id).first()
|
|
|
|
if not recurring:
|
|
return jsonify({'success': False, 'message': 'Recurring expense not found'}), 404
|
|
|
|
db.session.delete(recurring)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Recurring expense deleted'})
|
|
|
|
|
|
@bp.route('/<int:recurring_id>/create-expense', methods=['POST'])
|
|
@login_required
|
|
def create_expense_from_recurring(recurring_id):
|
|
"""Manually create an expense from a recurring expense"""
|
|
# Security: Filter by user_id
|
|
recurring = RecurringExpense.query.filter_by(id=recurring_id, user_id=current_user.id).first()
|
|
|
|
if not recurring:
|
|
return jsonify({'success': False, 'message': 'Recurring expense not found'}), 404
|
|
|
|
# Create expense
|
|
expense = Expense(
|
|
amount=recurring.amount,
|
|
currency=recurring.currency,
|
|
description=recurring.name,
|
|
category_id=recurring.category_id,
|
|
user_id=current_user.id,
|
|
tags=['recurring', recurring.frequency],
|
|
date=datetime.utcnow()
|
|
)
|
|
expense.set_tags(['recurring', recurring.frequency])
|
|
|
|
# Update recurring expense
|
|
recurring.last_created_date = datetime.utcnow()
|
|
recurring.next_due_date = calculate_next_due_date(
|
|
recurring.frequency,
|
|
recurring.day_of_period,
|
|
recurring.next_due_date
|
|
)
|
|
|
|
db.session.add(expense)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'expense': expense.to_dict(),
|
|
'recurring_expense': recurring.to_dict()
|
|
}), 201
|
|
|
|
|
|
@bp.route('/detect', methods=['POST'])
|
|
@login_required
|
|
def detect_recurring_patterns():
|
|
"""
|
|
Detect recurring expense patterns from historical expenses
|
|
Returns suggestions for potential recurring expenses
|
|
"""
|
|
# Get user's expenses from last 6 months
|
|
six_months_ago = datetime.utcnow() - relativedelta(months=6)
|
|
expenses = Expense.query.filter(
|
|
Expense.user_id == current_user.id,
|
|
Expense.date >= six_months_ago
|
|
).order_by(Expense.date.asc()).all()
|
|
|
|
if len(expenses) < 10:
|
|
return jsonify({
|
|
'suggestions': [],
|
|
'message': 'Not enough expense history to detect patterns'
|
|
})
|
|
|
|
# Group expenses by similar descriptions and amounts
|
|
patterns = defaultdict(list)
|
|
|
|
for expense in expenses:
|
|
# Normalize description (lowercase, remove numbers/special chars)
|
|
normalized_desc = re.sub(r'[^a-z\s]', '', expense.description.lower()).strip()
|
|
|
|
# Create a key based on normalized description and approximate amount
|
|
amount_bucket = round(expense.amount / 10) * 10 # Group by 10 currency units
|
|
key = f"{normalized_desc}_{amount_bucket}_{expense.category_id}"
|
|
|
|
patterns[key].append(expense)
|
|
|
|
suggestions = []
|
|
|
|
# Analyze patterns
|
|
for key, expense_list in patterns.items():
|
|
if len(expense_list) < 3: # Need at least 3 occurrences
|
|
continue
|
|
|
|
# Calculate intervals between expenses
|
|
intervals = []
|
|
for i in range(1, len(expense_list)):
|
|
days_diff = (expense_list[i].date - expense_list[i-1].date).days
|
|
intervals.append(days_diff)
|
|
|
|
if not intervals:
|
|
continue
|
|
|
|
avg_interval = sum(intervals) / len(intervals)
|
|
# Check variance to ensure consistency
|
|
variance = sum((x - avg_interval) ** 2 for x in intervals) / len(intervals)
|
|
std_dev = variance ** 0.5
|
|
|
|
# Determine if pattern is consistent
|
|
if std_dev / avg_interval > 0.3: # More than 30% variance
|
|
continue
|
|
|
|
# Determine frequency
|
|
frequency = None
|
|
day_of_period = None
|
|
confidence = 0
|
|
|
|
if 25 <= avg_interval <= 35: # Monthly
|
|
frequency = 'monthly'
|
|
# Get most common day of month
|
|
days = [e.date.day for e in expense_list]
|
|
day_of_period = max(set(days), key=days.count)
|
|
confidence = 90 - (std_dev / avg_interval * 100)
|
|
elif 6 <= avg_interval <= 8: # Weekly
|
|
frequency = 'weekly'
|
|
days = [e.date.weekday() for e in expense_list]
|
|
day_of_period = max(set(days), key=days.count)
|
|
confidence = 85 - (std_dev / avg_interval * 100)
|
|
elif 360 <= avg_interval <= 370: # Yearly
|
|
frequency = 'yearly'
|
|
confidence = 80 - (std_dev / avg_interval * 100)
|
|
|
|
if frequency and confidence > 60: # Only suggest if confidence > 60%
|
|
# Use most recent expense data
|
|
latest = expense_list[-1]
|
|
avg_amount = sum(e.amount for e in expense_list) / len(expense_list)
|
|
|
|
# Check if already exists as recurring expense
|
|
existing = RecurringExpense.query.filter_by(
|
|
user_id=current_user.id,
|
|
name=latest.description,
|
|
category_id=latest.category_id
|
|
).first()
|
|
|
|
if not existing:
|
|
suggestions.append({
|
|
'name': latest.description,
|
|
'amount': round(avg_amount, 2),
|
|
'currency': latest.currency,
|
|
'category_id': latest.category_id,
|
|
'category_name': latest.category.name,
|
|
'category_color': latest.category.color,
|
|
'frequency': frequency,
|
|
'day_of_period': day_of_period,
|
|
'confidence_score': round(confidence, 1),
|
|
'occurrences': len(expense_list),
|
|
'detected': True
|
|
})
|
|
|
|
# Sort by confidence score
|
|
suggestions.sort(key=lambda x: x['confidence_score'], reverse=True)
|
|
|
|
return jsonify({
|
|
'suggestions': suggestions[:10], # Return top 10
|
|
'message': f'Found {len(suggestions)} potential recurring expenses'
|
|
})
|
|
|
|
|
|
@bp.route('/accept-suggestion', methods=['POST'])
|
|
@login_required
|
|
def accept_suggestion():
|
|
"""Accept a detected recurring expense suggestion and create it"""
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('name') or not data.get('amount') or not data.get('category_id') or not data.get('frequency'):
|
|
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
|
|
|
|
# Security: Verify category belongs to current user
|
|
category = Category.query.filter_by(id=int(data.get('category_id')), user_id=current_user.id).first()
|
|
if not category:
|
|
return jsonify({'success': False, 'message': 'Invalid category'}), 400
|
|
|
|
# Calculate next due date
|
|
day_of_period = data.get('day_of_period')
|
|
next_due_date = calculate_next_due_date(data.get('frequency'), day_of_period)
|
|
|
|
# Create recurring expense
|
|
recurring = RecurringExpense(
|
|
name=data.get('name'),
|
|
amount=float(data.get('amount')),
|
|
currency=data.get('currency', current_user.currency),
|
|
category_id=int(data.get('category_id')),
|
|
frequency=data.get('frequency'),
|
|
day_of_period=day_of_period,
|
|
next_due_date=next_due_date,
|
|
auto_create=data.get('auto_create', False),
|
|
is_active=True,
|
|
detected=True, # Auto-detected
|
|
confidence_score=data.get('confidence_score', 0),
|
|
user_id=current_user.id
|
|
)
|
|
|
|
db.session.add(recurring)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'recurring_expense': recurring.to_dict()
|
|
}), 201
|
|
|
|
|
|
@bp.route('/upcoming', methods=['GET'])
|
|
@login_required
|
|
def get_upcoming_recurring():
|
|
"""Get upcoming recurring expenses (next 30 days)"""
|
|
# Security: Filter by user_id
|
|
thirty_days_later = datetime.utcnow() + timedelta(days=30)
|
|
|
|
recurring = RecurringExpense.query.filter(
|
|
RecurringExpense.user_id == current_user.id,
|
|
RecurringExpense.is_active == True,
|
|
RecurringExpense.next_due_date <= thirty_days_later
|
|
).order_by(RecurringExpense.next_due_date.asc()).all()
|
|
|
|
return jsonify({
|
|
'upcoming': [r.to_dict() for r in recurring]
|
|
})
|
|
|
|
|
|
@bp.route('/process-due', methods=['POST'])
|
|
@login_required
|
|
def process_due_manual():
|
|
"""
|
|
Manually trigger processing of due recurring expenses
|
|
Admin only for security - prevents users from spamming expense creation
|
|
"""
|
|
if not current_user.is_admin:
|
|
return jsonify({'success': False, 'message': 'Unauthorized'}), 403
|
|
|
|
try:
|
|
from app.scheduler import process_due_recurring_expenses
|
|
process_due_recurring_expenses()
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Recurring expenses processed successfully'
|
|
})
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': f'Error processing recurring expenses: {str(e)}'
|
|
}), 500
|
|
|
|
|
|
@bp.route('/sync-currency', methods=['POST'])
|
|
@login_required
|
|
def sync_currency():
|
|
"""
|
|
Sync all user's recurring expenses to use their current profile currency
|
|
Security: Only updates current user's recurring expenses
|
|
"""
|
|
try:
|
|
# Update all recurring expenses to match user's current currency
|
|
RecurringExpense.query.filter_by(user_id=current_user.id).update(
|
|
{'currency': current_user.currency}
|
|
)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'All recurring expenses synced to your current currency'
|
|
})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({
|
|
'success': False,
|
|
'message': f'Error syncing currency: {str(e)}'
|
|
}), 500
|