199 lines
8.1 KiB
Python
199 lines
8.1 KiB
Python
|
|
"""
|
||
|
|
Scheduler for background tasks like auto-creating recurring expenses and income
|
||
|
|
"""
|
||
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||
|
|
from apscheduler.triggers.cron import CronTrigger
|
||
|
|
from datetime import datetime
|
||
|
|
from app import db
|
||
|
|
from app.models import RecurringExpense, Expense, Income
|
||
|
|
from app.routes.recurring import calculate_next_due_date
|
||
|
|
from app.routes.income import calculate_income_next_due_date
|
||
|
|
import logging
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
def process_due_recurring_expenses():
|
||
|
|
"""
|
||
|
|
Process all due recurring expenses and create actual expenses for them
|
||
|
|
Security: User isolation is maintained through foreign keys
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
from app import create_app
|
||
|
|
app = create_app()
|
||
|
|
|
||
|
|
with app.app_context():
|
||
|
|
today = datetime.utcnow().date()
|
||
|
|
|
||
|
|
# Find all active recurring expenses that are due today or overdue and have auto_create enabled
|
||
|
|
due_recurring = RecurringExpense.query.filter(
|
||
|
|
RecurringExpense.is_active == True,
|
||
|
|
RecurringExpense.auto_create == True,
|
||
|
|
RecurringExpense.next_due_date <= datetime.utcnow()
|
||
|
|
).all()
|
||
|
|
|
||
|
|
created_count = 0
|
||
|
|
|
||
|
|
for recurring in due_recurring:
|
||
|
|
try:
|
||
|
|
# Check if we already created an expense today for this recurring expense
|
||
|
|
# to avoid duplicates
|
||
|
|
existing_today = Expense.query.filter(
|
||
|
|
Expense.user_id == recurring.user_id,
|
||
|
|
Expense.description == recurring.name,
|
||
|
|
Expense.category_id == recurring.category_id,
|
||
|
|
db.func.date(Expense.date) == today
|
||
|
|
).first()
|
||
|
|
|
||
|
|
if existing_today:
|
||
|
|
logger.info(f"Expense already exists for recurring ID {recurring.id} today, skipping")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Create the expense
|
||
|
|
expense = Expense(
|
||
|
|
amount=recurring.amount,
|
||
|
|
currency=recurring.currency,
|
||
|
|
description=recurring.name,
|
||
|
|
category_id=recurring.category_id,
|
||
|
|
user_id=recurring.user_id,
|
||
|
|
tags=['recurring', recurring.frequency, 'auto-created'],
|
||
|
|
date=datetime.utcnow()
|
||
|
|
)
|
||
|
|
expense.set_tags(['recurring', recurring.frequency, 'auto-created'])
|
||
|
|
|
||
|
|
db.session.add(expense)
|
||
|
|
|
||
|
|
# 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
|
||
|
|
)
|
||
|
|
|
||
|
|
created_count += 1
|
||
|
|
logger.info(f"Created expense from recurring ID {recurring.id} for user {recurring.user_id}")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error processing recurring expense ID {recurring.id}: {str(e)}")
|
||
|
|
db.session.rollback()
|
||
|
|
continue
|
||
|
|
|
||
|
|
if created_count > 0:
|
||
|
|
db.session.commit()
|
||
|
|
logger.info(f"Successfully created {created_count} expenses from recurring expenses")
|
||
|
|
else:
|
||
|
|
logger.info("No recurring expenses due for processing")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error in process_due_recurring_expenses: {str(e)}")
|
||
|
|
|
||
|
|
|
||
|
|
def process_due_recurring_income():
|
||
|
|
"""
|
||
|
|
Process all due recurring income and create actual income entries for them
|
||
|
|
Security: User isolation is maintained through foreign keys
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
from app import create_app
|
||
|
|
app = create_app()
|
||
|
|
|
||
|
|
with app.app_context():
|
||
|
|
today = datetime.utcnow().date()
|
||
|
|
|
||
|
|
# Find all active recurring income that are due today or overdue and have auto_create enabled
|
||
|
|
due_recurring = Income.query.filter(
|
||
|
|
Income.is_active == True,
|
||
|
|
Income.auto_create == True,
|
||
|
|
Income.frequency != 'once',
|
||
|
|
Income.next_due_date <= datetime.utcnow()
|
||
|
|
).all()
|
||
|
|
|
||
|
|
created_count = 0
|
||
|
|
|
||
|
|
for recurring in due_recurring:
|
||
|
|
try:
|
||
|
|
# Check if we already created income today for this recurring income
|
||
|
|
# to avoid duplicates
|
||
|
|
existing_today = Income.query.filter(
|
||
|
|
Income.user_id == recurring.user_id,
|
||
|
|
Income.description == recurring.description,
|
||
|
|
Income.source == recurring.source,
|
||
|
|
Income.frequency == 'once', # Only check one-time income entries
|
||
|
|
db.func.date(Income.date) == today
|
||
|
|
).first()
|
||
|
|
|
||
|
|
if existing_today:
|
||
|
|
logger.info(f"Income already exists for recurring ID {recurring.id} today, skipping")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Create the income entry
|
||
|
|
income = Income(
|
||
|
|
amount=recurring.amount,
|
||
|
|
currency=recurring.currency,
|
||
|
|
description=recurring.description,
|
||
|
|
source=recurring.source,
|
||
|
|
user_id=recurring.user_id,
|
||
|
|
tags=recurring.tags,
|
||
|
|
frequency='once', # Created income is one-time
|
||
|
|
date=datetime.utcnow()
|
||
|
|
)
|
||
|
|
|
||
|
|
db.session.add(income)
|
||
|
|
|
||
|
|
# Update recurring income
|
||
|
|
recurring.last_created_date = datetime.utcnow()
|
||
|
|
recurring.next_due_date = calculate_income_next_due_date(
|
||
|
|
recurring.frequency,
|
||
|
|
recurring.custom_days,
|
||
|
|
recurring.last_created_date
|
||
|
|
)
|
||
|
|
|
||
|
|
created_count += 1
|
||
|
|
logger.info(f"Created income from recurring ID {recurring.id} for user {recurring.user_id}")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error processing recurring income ID {recurring.id}: {str(e)}")
|
||
|
|
db.session.rollback()
|
||
|
|
continue
|
||
|
|
|
||
|
|
if created_count > 0:
|
||
|
|
db.session.commit()
|
||
|
|
logger.info(f"Successfully created {created_count} income entries from recurring income")
|
||
|
|
else:
|
||
|
|
logger.info("No recurring income due for processing")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error in process_due_recurring_income: {str(e)}")
|
||
|
|
|
||
|
|
|
||
|
|
def init_scheduler(app):
|
||
|
|
"""Initialize the background scheduler"""
|
||
|
|
scheduler = BackgroundScheduler()
|
||
|
|
|
||
|
|
# Run every hour to check for due recurring expenses
|
||
|
|
scheduler.add_job(
|
||
|
|
func=process_due_recurring_expenses,
|
||
|
|
trigger=CronTrigger(minute=0), # Run at the start of every hour
|
||
|
|
id='process_recurring_expenses',
|
||
|
|
name='Process due recurring expenses',
|
||
|
|
replace_existing=True
|
||
|
|
)
|
||
|
|
|
||
|
|
# Run every hour to check for due recurring income
|
||
|
|
scheduler.add_job(
|
||
|
|
func=process_due_recurring_income,
|
||
|
|
trigger=CronTrigger(minute=5), # Run 5 minutes past every hour
|
||
|
|
id='process_recurring_income',
|
||
|
|
name='Process due recurring income',
|
||
|
|
replace_existing=True
|
||
|
|
)
|
||
|
|
|
||
|
|
scheduler.start()
|
||
|
|
logger.info("Scheduler initialized - recurring expenses and income will be processed hourly")
|
||
|
|
|
||
|
|
# Shut down the scheduler when exiting the app
|
||
|
|
import atexit
|
||
|
|
atexit.register(lambda: scheduler.shutdown())
|
||
|
|
|
||
|
|
return scheduler
|