mitlist/be/app/jobs/recurring_expenses.py
Mohamad 29e42b8d80 feat: Enhance cost management features with new endpoints and services
This commit introduces significant updates to the cost management functionality, including:

- New endpoints for retrieving cost summaries and generating expenses from lists, improving user interaction with financial data.
- Implementation of a service layer for cost-related logic, encapsulating the core business rules for calculating cost summaries and managing expenses.
- Introduction of a financial activity endpoint to consolidate user expenses and settlements, enhancing the user experience by providing a comprehensive view of financial activities.
- Refactoring of existing code to improve maintainability and clarity, including the addition of new exception handling for financial conflicts.

These changes aim to streamline cost management processes and enhance the overall functionality of the application.
2025-06-21 00:53:03 +02:00

162 lines
6.5 KiB
Python

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