Initial commit

This commit is contained in:
iulian 2025-12-26 00:52:56 +00:00
commit 983cee0320
322 changed files with 57174 additions and 0 deletions

408
app/routes/income.py Normal file
View file

@ -0,0 +1,408 @@
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('/<int:income_id>', 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('/<int:income_id>', 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('/<int:income_id>/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('/<int:income_id>/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
})