
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.
162 lines
6.5 KiB
Python
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 |