from flask import Blueprint, request, jsonify, current_app from flask_login import login_required, current_user from app import db from app.models import Income from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta import json bp = Blueprint('income', __name__, url_prefix='/api/income') def calculate_income_next_due_date(frequency, custom_days=None, from_date=None): """Calculate next due date for recurring income based on frequency Args: frequency: 'once', 'weekly', 'biweekly', 'every4weeks', 'monthly', 'custom' custom_days: Number of days for custom frequency from_date: Starting date (default: today) Returns: Next due date or None for one-time income """ if frequency == 'once': return None if from_date is None: from_date = datetime.utcnow() if frequency == 'weekly': return from_date + timedelta(days=7) elif frequency == 'biweekly': return from_date + timedelta(days=14) elif frequency == 'every4weeks': return from_date + timedelta(days=28) elif frequency == 'monthly': return from_date + relativedelta(months=1) elif frequency == 'custom' and custom_days: return from_date + timedelta(days=custom_days) return None @bp.route('/', methods=['GET']) @login_required def get_income(): """Get income entries with filtering and pagination Security: Only returns income for current_user """ current_app.logger.info(f"Getting income for user {current_user.id}") page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) start_date = request.args.get('start_date') end_date = request.args.get('end_date') source = request.args.get('source') search = request.args.get('search', '') # Security: Filter by current user query = Income.query.filter_by(user_id=current_user.id) if source: query = query.filter_by(source=source) if start_date: query = query.filter(Income.date >= datetime.fromisoformat(start_date)) if end_date: query = query.filter(Income.date <= datetime.fromisoformat(end_date)) if search: query = query.filter(Income.description.ilike(f'%{search}%')) pagination = query.order_by(Income.date.desc()).paginate( page=page, per_page=per_page, error_out=False ) current_app.logger.info(f"Found {pagination.total} income entries for user {current_user.id}") return jsonify({ 'income': [inc.to_dict() for inc in pagination.items], 'total': pagination.total, 'pages': pagination.pages, 'current_page': page }) @bp.route('/', methods=['POST']) @login_required def create_income(): """Create new income entry Security: Only creates income for current_user """ data = request.get_json() current_app.logger.info(f"Creating income for user {current_user.id}, data: {data}") # Validate required fields if not data or not data.get('amount') or not data.get('source') or not data.get('description'): current_app.logger.warning(f"Missing required fields: {data}") return jsonify({'success': False, 'message': 'Missing required fields'}), 400 try: income_date = datetime.fromisoformat(data.get('date')) if data.get('date') else datetime.utcnow() frequency = data.get('frequency', 'once') custom_days = data.get('custom_days') auto_create = data.get('auto_create', False) # Calculate next due date for recurring income next_due_date = None if frequency != 'once' and auto_create: next_due_date = calculate_income_next_due_date(frequency, custom_days, income_date) # Create income entry income = Income( amount=float(data.get('amount')), currency=data.get('currency', current_user.currency), description=data.get('description'), source=data.get('source'), user_id=current_user.id, tags=json.dumps(data.get('tags', [])) if isinstance(data.get('tags'), list) else data.get('tags', '[]'), frequency=frequency, custom_days=custom_days, next_due_date=next_due_date, is_active=True, auto_create=auto_create, date=income_date ) current_app.logger.info(f"Adding income to session: {income.description}") db.session.add(income) db.session.commit() current_app.logger.info(f"Income committed with ID: {income.id}") # Verify it was saved saved_income = Income.query.filter_by(id=income.id).first() current_app.logger.info(f"Verification - Income exists: {saved_income is not None}") return jsonify({ 'success': True, 'message': 'Income added successfully', 'income': income.to_dict() }), 201 except Exception as e: db.session.rollback() current_app.logger.error(f"Error creating income: {str(e)}", exc_info=True) return jsonify({'success': False, 'message': 'Failed to create income'}), 500 @bp.route('/', methods=['PUT']) @login_required def update_income(income_id): """Update income entry Security: Only allows updating user's own income """ # Security check: verify income belongs to current user income = Income.query.filter_by(id=income_id, user_id=current_user.id).first() if not income: return jsonify({'success': False, 'message': 'Income not found'}), 404 data = request.get_json() try: # Update fields if 'amount' in data: income.amount = float(data['amount']) if 'currency' in data: income.currency = data['currency'] if 'description' in data: income.description = data['description'] if 'source' in data: income.source = data['source'] if 'tags' in data: income.tags = json.dumps(data['tags']) if isinstance(data['tags'], list) else data['tags'] if 'date' in data: income.date = datetime.fromisoformat(data['date']) # Handle frequency changes frequency_changed = False if 'frequency' in data and data['frequency'] != income.frequency: income.frequency = data['frequency'] frequency_changed = True if 'custom_days' in data: income.custom_days = data['custom_days'] frequency_changed = True if 'auto_create' in data: income.auto_create = data['auto_create'] if 'is_active' in data: income.is_active = data['is_active'] # Recalculate next_due_date if frequency changed or auto_create enabled if (frequency_changed or 'auto_create' in data) and income.auto_create and income.is_active: if income.frequency != 'once': from_date = income.last_created_date if income.last_created_date else income.date income.next_due_date = calculate_income_next_due_date( income.frequency, income.custom_days, from_date ) else: income.next_due_date = None elif not income.auto_create or not income.is_active: income.next_due_date = None income.updated_at = datetime.utcnow() db.session.commit() return jsonify({ 'success': True, 'message': 'Income updated successfully', 'income': income.to_dict() }) except Exception as e: db.session.rollback() current_app.logger.error(f"Error updating income: {str(e)}") return jsonify({'success': False, 'message': 'Failed to update income'}), 500 @bp.route('/', methods=['DELETE']) @login_required def delete_income(income_id): """Delete income entry Security: Only allows deleting user's own income """ # Security check: verify income belongs to current user income = Income.query.filter_by(id=income_id, user_id=current_user.id).first() if not income: return jsonify({'success': False, 'message': 'Income not found'}), 404 try: db.session.delete(income) db.session.commit() return jsonify({ 'success': True, 'message': 'Income deleted successfully' }) except Exception as e: db.session.rollback() current_app.logger.error(f"Error deleting income: {str(e)}") return jsonify({'success': False, 'message': 'Failed to delete income'}), 500 @bp.route('//toggle', methods=['PUT']) @login_required def toggle_recurring_income(income_id): """Toggle recurring income active status Security: Only allows toggling user's own income """ # Security check: verify income belongs to current user income = Income.query.filter_by(id=income_id, user_id=current_user.id).first() if not income: return jsonify({'success': False, 'message': 'Income not found'}), 404 try: income.is_active = not income.is_active # Clear next_due_date if deactivated if not income.is_active: income.next_due_date = None elif income.auto_create and income.frequency != 'once': # Recalculate next_due_date when reactivated from_date = income.last_created_date if income.last_created_date else income.date income.next_due_date = calculate_income_next_due_date( income.frequency, income.custom_days, from_date ) income.updated_at = datetime.utcnow() db.session.commit() return jsonify({ 'success': True, 'message': f'Income {"activated" if income.is_active else "deactivated"}', 'income': income.to_dict() }) except Exception as e: db.session.rollback() current_app.logger.error(f"Error toggling income: {str(e)}") return jsonify({'success': False, 'message': 'Failed to toggle income'}), 500 @bp.route('//create-now', methods=['POST']) @login_required def create_income_now(income_id): """Manually create income entry from recurring income Security: Only allows creating from user's own recurring income """ # Security check: verify income belongs to current user recurring_income = Income.query.filter_by(id=income_id, user_id=current_user.id).first() if not recurring_income: return jsonify({'success': False, 'message': 'Recurring income not found'}), 404 if recurring_income.frequency == 'once': return jsonify({'success': False, 'message': 'This is not a recurring income'}), 400 try: # Create new income entry based on recurring income new_income = Income( amount=recurring_income.amount, currency=recurring_income.currency, description=recurring_income.description, source=recurring_income.source, user_id=current_user.id, tags=recurring_income.tags, frequency='once', # Created income is one-time date=datetime.utcnow() ) db.session.add(new_income) # Update recurring income's next due date and last created date recurring_income.last_created_date = datetime.utcnow() if recurring_income.auto_create and recurring_income.is_active: recurring_income.next_due_date = calculate_income_next_due_date( recurring_income.frequency, recurring_income.custom_days, recurring_income.last_created_date ) db.session.commit() return jsonify({ 'success': True, 'message': 'Income created successfully', 'income': new_income.to_dict(), 'recurring_income': recurring_income.to_dict() }), 201 except Exception as e: db.session.rollback() current_app.logger.error(f"Error creating income from recurring: {str(e)}") return jsonify({'success': False, 'message': 'Failed to create income'}), 500 @bp.route('/sources', methods=['GET']) @login_required def get_income_sources(): """Get list of income sources Returns predefined sources for consistency """ sources = [ {'value': 'Salary', 'label': 'Salary', 'icon': 'payments'}, {'value': 'Freelance', 'label': 'Freelance', 'icon': 'work'}, {'value': 'Investment', 'label': 'Investment', 'icon': 'trending_up'}, {'value': 'Rental', 'label': 'Rental Income', 'icon': 'home'}, {'value': 'Gift', 'label': 'Gift', 'icon': 'card_giftcard'}, {'value': 'Bonus', 'label': 'Bonus', 'icon': 'star'}, {'value': 'Refund', 'label': 'Refund', 'icon': 'refresh'}, {'value': 'Other', 'label': 'Other', 'icon': 'category'} ] return jsonify({'sources': sources}) @bp.route('/summary', methods=['GET']) @login_required def get_income_summary(): """Get income summary for dashboard Security: Only returns data for current_user """ start_date = request.args.get('start_date') end_date = request.args.get('end_date') # Security: Filter by current user query = Income.query.filter_by(user_id=current_user.id) if start_date: query = query.filter(Income.date >= datetime.fromisoformat(start_date)) if end_date: query = query.filter(Income.date <= datetime.fromisoformat(end_date)) # Calculate totals by source income_by_source = db.session.query( Income.source, db.func.sum(Income.amount).label('total'), db.func.count(Income.id).label('count') ).filter_by(user_id=current_user.id) if start_date: income_by_source = income_by_source.filter(Income.date >= datetime.fromisoformat(start_date)) if end_date: income_by_source = income_by_source.filter(Income.date <= datetime.fromisoformat(end_date)) income_by_source = income_by_source.group_by(Income.source).all() total_income = sum(item.total for item in income_by_source) breakdown = [ { 'source': item.source, 'total': float(item.total), 'count': item.count, 'percentage': (float(item.total) / total_income * 100) if total_income > 0 else 0 } for item in income_by_source ] return jsonify({ 'total_income': total_income, 'count': sum(item.count for item in income_by_source), 'breakdown': breakdown })