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 import enum 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)}", exc_info=True) continue except Exception as e: logger.error(f"Error in generate_recurring_expenses job during expense fetch: {str(e)}", exc_info=True) # Do not re-raise, allow the job scheduler to run again later 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 recurrence pattern provided.""" if not current_date: return None # Extract a lowercase string of the recurrence type regardless of whether it is an Enum member or a str. if isinstance(pattern.type, enum.Enum): pattern_type = pattern.type.value.lower() else: pattern_type = str(pattern.type).lower() next_date: Optional[datetime] = 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() # ``days_of_week`` can be stored either as a list[int] (Python-side) or as a # comma-separated string in the database. We normalise it to a list[int]. days_of_week_iterable = [] if pattern.days_of_week is None: days_of_week_iterable = [] elif isinstance(pattern.days_of_week, (list, tuple)): days_of_week_iterable = list(pattern.days_of_week) else: # Assume comma-separated string like "1,3,5" try: days_of_week_iterable = [int(d.strip()) for d in str(pattern.days_of_week).split(',') if d.strip().isdigit()] except Exception: days_of_week_iterable = [] # Find the next valid weekday after the current one next_days = sorted([d for d in days_of_week_iterable if d > current_weekday]) if next_days: days_ahead = next_days[0] - current_weekday next_date = current_date + timedelta(days=days_ahead) else: # Jump to the first valid day in a future week respecting the interval if days_of_week_iterable: days_ahead = (7 - current_weekday) + min(days_of_week_iterable) next_date = current_date + timedelta(days=days_ahead) if pattern.interval > 1: next_date += timedelta(weeks=pattern.interval - 1) elif pattern_type == 'monthly': # Move `interval` months forward while keeping the day component stable where possible. year = current_date.year + (current_date.month + pattern.interval - 1) // 12 month = (current_date.month + pattern.interval - 1) % 12 + 1 try: next_date = current_date.replace(year=year, month=month) except ValueError: # Handle cases like Feb-31st by rolling back to the last valid day of the new month. next_date = (current_date.replace(day=1, year=year, month=month) + 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 edge-case; fallback to Feb-28 if Feb-29 does not exist in the target year. next_date = current_date.replace(year=current_date.year + pattern.interval, day=28) # Stop recurrence if beyond end_date if pattern.end_date and next_date and next_date > pattern.end_date: return None return next_date