from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ from sqlalchemy.orm import selectinload from app.models import Expense, RecurrencePattern, SplitTypeEnum from app.crud.expense import create_expense from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate import logging from typing import Optional logger = logging.getLogger(__name__) async def generate_recurring_expenses(db: AsyncSession) -> None: """ Background job to generate recurring expenses. Should be run daily to check for and create new recurring expenses. """ try: now = datetime.utcnow() query = select(Expense).join(RecurrencePattern).where( and_( Expense.is_recurring == True, Expense.next_occurrence <= now, ( (RecurrencePattern.max_occurrences == None) | (RecurrencePattern.max_occurrences > 0) ), ( (RecurrencePattern.end_date == None) | (RecurrencePattern.end_date > now) ) ) ).options( selectinload(Expense.splits) # Eager load splits to use as a template ) result = await db.execute(query) recurring_expenses = result.scalars().all() for expense in recurring_expenses: try: await _generate_next_occurrence(db, expense) except Exception as e: logger.error(f"Error generating next occurrence for expense {expense.id}: {str(e)}") continue except Exception as e: logger.error(f"Error in generate_recurring_expenses job: {str(e)}") raise async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None: """Generate the next occurrence of a recurring expense.""" pattern = expense.recurrence_pattern if not pattern: logger.warning(f"Recurring expense {expense.id} is missing its recurrence pattern.") return next_date = _calculate_next_occurrence(expense.next_occurrence, pattern) if not next_date: logger.info(f"No next occurrence date for expense {expense.id}, stopping recurrence.") expense.is_recurring = False # Stop future processing await db.flush() return # Recreate splits from the template expense if needed splits_data = None if expense.split_type not in [SplitTypeEnum.EQUAL, SplitTypeEnum.ITEM_BASED]: if not expense.splits: logger.error(f"Cannot generate next occurrence for expense {expense.id} with split type {expense.split_type.value} because it has no splits to use as a template.") return splits_data = [ ExpenseSplitCreate( user_id=split.user_id, owed_amount=split.owed_amount, share_percentage=split.share_percentage, share_units=split.share_units, ) for split in expense.splits ] new_expense = ExpenseCreate( description=expense.description, total_amount=expense.total_amount, currency=expense.currency, expense_date=next_date, split_type=expense.split_type, list_id=expense.list_id, group_id=expense.group_id, item_id=expense.item_id, paid_by_user_id=expense.paid_by_user_id, is_recurring=False, # The new expense is a single occurrence, not a recurring template splits_in=splits_data ) # We pass the original creator's ID created_expense = await create_expense(db, new_expense, expense.created_by_user_id) logger.info(f"Generated new expense {created_expense.id} from recurring expense {expense.id}.") # Update the template expense for the next run expense.last_occurrence = next_date next_next_date = _calculate_next_occurrence(next_date, pattern) # Decrement occurrence count if it exists if pattern.max_occurrences is not None: pattern.max_occurrences -= 1 if pattern.max_occurrences <= 0: next_next_date = None # Stop recurrence expense.next_occurrence = next_next_date if not expense.next_occurrence: expense.is_recurring = False # End the recurrence await db.flush() def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePattern) -> Optional[datetime]: """Calculate the next occurrence date based on the pattern.""" if not current_date: return None next_date = None if pattern.type == 'daily': next_date = current_date + timedelta(days=pattern.interval) elif pattern.type == 'weekly': if not pattern.days_of_week: next_date = current_date + timedelta(weeks=pattern.interval) else: current_weekday = current_date.weekday() # Find the next valid weekday next_days = sorted([d for d in pattern.days_of_week if d > current_weekday]) if next_days: # Next occurrence is in the same week days_ahead = next_days[0] - current_weekday next_date = current_date + timedelta(days=days_ahead) else: # Next occurrence is in the following week(s) days_ahead = (7 - current_weekday) + min(pattern.days_of_week) next_date = current_date + timedelta(days=days_ahead) if pattern.interval > 1: next_date += timedelta(weeks=pattern.interval - 1) elif pattern.type == 'monthly': year = current_date.year + (current_date.month + pattern.interval - 1) // 12 month = (current_date.month + pattern.interval - 1) % 12 + 1 # Handle cases where the day is invalid for the new month (e.g., 31st) try: next_date = current_date.replace(year=year, month=month) except ValueError: # Go to the last day of the new month next_date = (current_date.replace(year=year, month=month, day=1) + timedelta(days=31)).replace(day=1) - timedelta(days=1) elif pattern.type == 'yearly': try: next_date = current_date.replace(year=current_date.year + pattern.interval) except ValueError: # Leap year case (Feb 29) next_date = current_date.replace(year=current_date.year + pattern.interval, day=28) # Check against end_date if pattern.end_date and next_date and next_date > pattern.end_date: return None return next_date