diff --git a/be/app/api/v1/endpoints/costs.py b/be/app/api/v1/endpoints/costs.py index f7adb00..81490f1 100644 --- a/be/app/api/v1/endpoints/costs.py +++ b/be/app/api/v1/endpoints/costs.py @@ -1,113 +1,24 @@ - import logging from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session, selectinload -from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN -from typing import List from app.database import get_transactional_session from app.auth import current_active_user -from app.models import ( - User as UserModel, - Group as GroupModel, - List as ListModel, - Expense as ExpenseModel, - Item as ItemModel, - UserGroup as UserGroupModel, - SplitTypeEnum, - ExpenseSplit as ExpenseSplitModel, - SettlementActivity as SettlementActivityModel, - Settlement as SettlementModel +from app.models import User as UserModel, Expense as ExpenseModel +from app.schemas.cost import ListCostSummary, GroupBalanceSummary +from app.schemas.expense import ExpensePublic +from app.services import costs_service +from app.core.exceptions import ( + ListNotFoundError, + ListPermissionError, + GroupNotFoundError, + GroupPermissionError, + InvalidOperationError ) -from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement -from app.schemas.expense import ExpenseCreate -from app.crud import list as crud_list -from app.crud import expense as crud_expense -from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError logger = logging.getLogger(__name__) router = APIRouter() -def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> List[SuggestedSettlement]: - """ - Calculate suggested settlements to balance the finances within a group. - - This function takes the current balances of all users and suggests optimal settlements - to minimize the number of transactions needed to settle all debts. - - Args: - user_balances: List of UserBalanceDetail objects with their current balances - - Returns: - List of SuggestedSettlement objects representing the suggested payments - """ - # Create list of users who owe money (negative balance) and who are owed money (positive balance) - debtors = [] # Users who owe money (negative balance) - creditors = [] # Users who are owed money (positive balance) - - # Threshold to consider a balance as zero due to floating point precision - epsilon = Decimal('0.01') - - # Sort users into debtors and creditors - for user in user_balances: - # Skip users with zero balance (or very close to zero) - if abs(user.net_balance) < epsilon: - continue - - if user.net_balance < Decimal('0'): - # User owes money - debtors.append({ - 'user_id': user.user_id, - 'user_identifier': user.user_identifier, - 'amount': -user.net_balance # Convert to positive amount - }) - else: - # User is owed money - creditors.append({ - 'user_id': user.user_id, - 'user_identifier': user.user_identifier, - 'amount': user.net_balance - }) - - # Sort by amount (descending) to handle largest debts first - debtors.sort(key=lambda x: x['amount'], reverse=True) - creditors.sort(key=lambda x: x['amount'], reverse=True) - - settlements = [] - - # Iterate through debtors and match them with creditors - while debtors and creditors: - debtor = debtors[0] - creditor = creditors[0] - - # Determine the settlement amount (the smaller of the two amounts) - amount = min(debtor['amount'], creditor['amount']).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - - # Create settlement record - if amount > Decimal('0'): - settlements.append( - SuggestedSettlement( - from_user_id=debtor['user_id'], - from_user_identifier=debtor['user_identifier'], - to_user_id=creditor['user_id'], - to_user_identifier=creditor['user_identifier'], - amount=amount - ) - ) - - # Update balances - debtor['amount'] -= amount - creditor['amount'] -= amount - - # Remove users who have settled their debts/credits - if debtor['amount'] < epsilon: - debtors.pop(0) - if creditor['amount'] < epsilon: - creditors.pop(0) - - return settlements @router.get( "/lists/{list_id}/cost-summary", @@ -116,8 +27,8 @@ def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> L tags=["Costs"], responses={ status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this list"}, - status.HTTP_404_NOT_FOUND: {"description": "List or associated user not found"} - } + status.HTTP_404_NOT_FOUND: {"description": "List not found"}, + }, ) async def get_list_cost_summary( list_id: int, @@ -125,151 +36,62 @@ async def get_list_cost_summary( current_user: UserModel = Depends(current_active_user), ): """ - Retrieves a calculated cost summary for a specific list, detailing total costs, - equal shares per user, and individual user balances based on their contributions. - - The user must have access to the list to view its cost summary. - Costs are split among group members if the list belongs to a group, or just for - the creator if it's a personal list. All users who added items with prices are - included in the calculation. + Retrieves a calculated cost summary for a specific list. + If an expense has been generated for this list, the summary will be based on that. + Otherwise, it will be a basic summary of item prices. + This endpoint is idempotent and does not create any data. """ logger.info(f"User {current_user.email} requesting cost summary for list {list_id}") - - # 1. Verify user has access to the target list try: - await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + return await costs_service.get_list_cost_summary_logic( + db=db, list_id=list_id, current_user_id=current_user.id + ) except ListPermissionError as e: logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(e)}") - raise + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) except ListNotFoundError as e: - logger.warning(f"List {list_id} not found when checking permissions for cost summary: {str(e)}") - raise + logger.warning(f"List {list_id} not found when getting cost summary: {str(e)}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - # 2. Get the list with its items and users - list_result = await db.execute( - select(ListModel) - .options( - selectinload(ListModel.items).options(selectinload(ItemModel.added_by_user)), - selectinload(ListModel.group).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))), - selectinload(ListModel.creator) + +@router.post( + "/lists/{list_id}/cost-summary", + response_model=ExpensePublic, + status_code=status.HTTP_201_CREATED, + summary="Generate and Get Expense from List Summary", + tags=["Costs"], + responses={ + status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this list"}, + status.HTTP_404_NOT_FOUND: {"description": "List not found"}, + status.HTTP_400_BAD_REQUEST: {"description": "Invalid operation (e.g., no items to expense, or expense already exists)"}, + }, +) +async def generate_expense_from_list_summary( + list_id: int, + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """ + Creates an ITEM_BASED expense from the items in a given list. + This should be called to finalize the costs for a shopping list and turn it into a formal expense. + It will fail if an expense for this list already exists. + """ + logger.info(f"User {current_user.email} requesting to generate expense from list {list_id}") + try: + expense = await costs_service.generate_expense_from_list_logic( + db=db, list_id=list_id, current_user_id=current_user.id ) - .where(ListModel.id == list_id) - ) - db_list = list_result.scalars().first() - if not db_list: - raise ListNotFoundError(list_id) + return expense + except (ListPermissionError, GroupPermissionError) as e: + logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(e)}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except (ListNotFoundError, GroupNotFoundError) as e: + logger.warning(f"Resource not found for list {list_id}: {str(e)}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except InvalidOperationError as e: + logger.warning(f"Invalid operation for list {list_id}: {str(e)}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - # 3. Get or create an expense for this list - expense_result = await db.execute( - select(ExpenseModel) - .where(ExpenseModel.list_id == list_id) - .options(selectinload(ExpenseModel.splits)) - ) - db_expense = expense_result.scalars().first() - - if not db_expense: - # Create a new expense for this list - total_amount = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0")) - if total_amount == Decimal("0"): - return ListCostSummary( - list_id=db_list.id, - list_name=db_list.name, - total_list_cost=Decimal("0.00"), - num_participating_users=0, - equal_share_per_user=Decimal("0.00"), - user_balances=[] - ) - - # Create expense with ITEM_BASED split type - expense_in = ExpenseCreate( - description=f"Cost summary for list {db_list.name}", - total_amount=total_amount, - list_id=list_id, - split_type=SplitTypeEnum.ITEM_BASED, - paid_by_user_id=db_list.creator.id - ) - db_expense = await crud_expense.create_expense(db=db, expense_in=expense_in, current_user_id=current_user.id) - - # 4. Calculate cost summary from expense splits - participating_users = set() - user_items_added_value = {} - total_list_cost = Decimal("0.00") - - # Get all users who added items - for item in db_list.items: - if item.price is not None and item.price > Decimal("0") and item.added_by_user: - participating_users.add(item.added_by_user) - user_items_added_value[item.added_by_user.id] = user_items_added_value.get(item.added_by_user.id, Decimal("0.00")) + item.price - total_list_cost += item.price - - # Get all users from expense splits - for split in db_expense.splits: - if split.user: - participating_users.add(split.user) - - num_participating_users = len(participating_users) - if num_participating_users == 0: - return ListCostSummary( - list_id=db_list.id, - list_name=db_list.name, - total_list_cost=Decimal("0.00"), - num_participating_users=0, - equal_share_per_user=Decimal("0.00"), - user_balances=[] - ) - - # This is the ideal equal share, returned in the summary - equal_share_per_user_for_response = (total_list_cost / Decimal(num_participating_users)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - - # Sort users for deterministic remainder distribution - sorted_participating_users = sorted(list(participating_users), key=lambda u: u.id) - - user_final_shares = {} - if num_participating_users > 0: - base_share_unrounded = total_list_cost / Decimal(num_participating_users) - - # Calculate initial share for each user, rounding down - for user in sorted_participating_users: - user_final_shares[user.id] = base_share_unrounded.quantize(Decimal("0.01"), rounding=ROUND_DOWN) - - # Calculate sum of rounded down shares - sum_of_rounded_shares = sum(user_final_shares.values()) - - # Calculate remaining pennies to be distributed - remaining_pennies = int(((total_list_cost - sum_of_rounded_shares) * Decimal("100")).to_integral_value(rounding=ROUND_HALF_UP)) - - # Distribute remaining pennies one by one to sorted users - for i in range(remaining_pennies): - user_to_adjust = sorted_participating_users[i % num_participating_users] - user_final_shares[user_to_adjust.id] += Decimal("0.01") - - user_balances = [] - for user in sorted_participating_users: # Iterate over sorted users - items_added = user_items_added_value.get(user.id, Decimal("0.00")) - # current_user_share is now the precisely calculated share for this user - current_user_share = user_final_shares.get(user.id, Decimal("0.00")) - - balance = items_added - current_user_share - user_identifier = user.name if user.name else user.email - user_balances.append( - UserCostShare( - user_id=user.id, - user_identifier=user_identifier, - items_added_value=items_added.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - amount_due=current_user_share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - balance=balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - ) - ) - - user_balances.sort(key=lambda x: x.user_identifier) - return ListCostSummary( - list_id=db_list.id, - list_name=db_list.name, - total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - num_participating_users=num_participating_users, - equal_share_per_user=equal_share_per_user_for_response, # Use the ideal share for the response field - user_balances=user_balances - ) @router.get( "/groups/{group_id}/balance-summary", @@ -278,8 +100,8 @@ async def get_list_cost_summary( tags=["Costs", "Groups"], responses={ status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this group"}, - status.HTTP_404_NOT_FOUND: {"description": "Group not found"} - } + status.HTTP_404_NOT_FOUND: {"description": "Group not found"}, + }, ) async def get_group_balance_summary( group_id: int, @@ -292,132 +114,13 @@ async def get_group_balance_summary( The user must be a member of the group to view its balance summary. """ logger.info(f"User {current_user.email} requesting balance summary for group {group_id}") - - # 1. Verify user is a member of the target group - group_check = await db.execute( - select(GroupModel) - .options(selectinload(GroupModel.member_associations)) - .where(GroupModel.id == group_id) - ) - db_group_for_check = group_check.scalars().first() - - if not db_group_for_check: - raise GroupNotFoundError(group_id) - - user_is_member = any(assoc.user_id == current_user.id for assoc in db_group_for_check.member_associations) - if not user_is_member: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User not a member of group {group_id}") - - # 2. Get all expenses and settlements for the group - expenses_result = await db.execute( - select(ExpenseModel) - .where(ExpenseModel.group_id == group_id) - .options(selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user)) - ) - expenses = expenses_result.scalars().all() - - settlements_result = await db.execute( - select(SettlementModel) - .where(SettlementModel.group_id == group_id) - .options( - selectinload(SettlementModel.paid_by_user), - selectinload(SettlementModel.paid_to_user) + try: + return await costs_service.get_group_balance_summary_logic( + db=db, group_id=group_id, current_user_id=current_user.id ) - ) - settlements = settlements_result.scalars().all() - - # Fetch SettlementActivities related to the group's expenses - # This requires joining SettlementActivity -> ExpenseSplit -> Expense - settlement_activities_result = await db.execute( - select(SettlementActivityModel) - .join(ExpenseSplitModel, SettlementActivityModel.expense_split_id == ExpenseSplitModel.id) - .join(ExpenseModel, ExpenseSplitModel.expense_id == ExpenseModel.id) - .where(ExpenseModel.group_id == group_id) - .options(selectinload(SettlementActivityModel.payer)) # Optional: if you need payer details directly - ) - settlement_activities = settlement_activities_result.scalars().all() - - # 3. Calculate user balances - user_balances_data = {} - # Initialize UserBalanceDetail for each group member - for assoc in db_group_for_check.member_associations: - if assoc.user: - user_balances_data[assoc.user.id] = { - "user_id": assoc.user.id, - "user_identifier": assoc.user.name if assoc.user.name else assoc.user.email, - "total_paid_for_expenses": Decimal("0.00"), - "initial_total_share_of_expenses": Decimal("0.00"), - "total_amount_paid_via_settlement_activities": Decimal("0.00"), - "total_generic_settlements_paid": Decimal("0.00"), - "total_generic_settlements_received": Decimal("0.00"), - } - - # Process Expenses - for expense in expenses: - if expense.paid_by_user_id in user_balances_data: - user_balances_data[expense.paid_by_user_id]["total_paid_for_expenses"] += expense.total_amount - - for split in expense.splits: - if split.user_id in user_balances_data: - user_balances_data[split.user_id]["initial_total_share_of_expenses"] += split.owed_amount - - # Process Settlement Activities (SettlementActivityModel) - for activity in settlement_activities: - if activity.paid_by_user_id in user_balances_data: - user_balances_data[activity.paid_by_user_id]["total_amount_paid_via_settlement_activities"] += activity.amount_paid - - # Process Generic Settlements (SettlementModel) - for settlement in settlements: - if settlement.paid_by_user_id in user_balances_data: - user_balances_data[settlement.paid_by_user_id]["total_generic_settlements_paid"] += settlement.amount - if settlement.paid_to_user_id in user_balances_data: - user_balances_data[settlement.paid_to_user_id]["total_generic_settlements_received"] += settlement.amount - - # Calculate Final Balances - final_user_balances = [] - for user_id, data in user_balances_data.items(): - initial_total_share_of_expenses = data["initial_total_share_of_expenses"] - total_amount_paid_via_settlement_activities = data["total_amount_paid_via_settlement_activities"] - - adjusted_total_share_of_expenses = initial_total_share_of_expenses - total_amount_paid_via_settlement_activities - - total_paid_for_expenses = data["total_paid_for_expenses"] - total_generic_settlements_received = data["total_generic_settlements_received"] - total_generic_settlements_paid = data["total_generic_settlements_paid"] - - net_balance = ( - total_paid_for_expenses + total_generic_settlements_received - ) - (adjusted_total_share_of_expenses + total_generic_settlements_paid) - - # Quantize all final values for UserBalanceDetail schema - user_detail = UserBalanceDetail( - user_id=data["user_id"], - user_identifier=data["user_identifier"], - total_paid_for_expenses=total_paid_for_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - # Store adjusted_total_share_of_expenses in total_share_of_expenses - total_share_of_expenses=adjusted_total_share_of_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - # Store total_generic_settlements_paid in total_settlements_paid - total_settlements_paid=total_generic_settlements_paid.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - total_settlements_received=total_generic_settlements_received.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - net_balance=net_balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - ) - final_user_balances.append(user_detail) - - # Sort by user identifier - final_user_balances.sort(key=lambda x: x.user_identifier) - - # Calculate suggested settlements - suggested_settlements = calculate_suggested_settlements(final_user_balances) - - # Calculate overall totals for the group - overall_total_expenses = sum(expense.total_amount for expense in expenses) - overall_total_settlements = sum(settlement.amount for settlement in settlements) - - return GroupBalanceSummary( - group_id=db_group_for_check.id, - group_name=db_group_for_check.name, - overall_total_expenses=overall_total_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - overall_total_settlements=overall_total_settlements.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - user_balances=final_user_balances, - suggested_settlements=suggested_settlements - ) \ No newline at end of file + except GroupPermissionError as e: + logger.warning(f"Permission denied for user {current_user.email} on group {group_id}: {str(e)}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except GroupNotFoundError as e: + logger.warning(f"Group {group_id} not found when getting balance summary: {str(e)}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/financials.py b/be/app/api/v1/endpoints/financials.py index 0013e41..2acac2f 100644 --- a/be/app/api/v1/endpoints/financials.py +++ b/be/app/api/v1/endpoints/financials.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import joinedload -from typing import List as PyList, Optional, Sequence +from typing import List as PyList, Optional, Sequence, Union from app.database import get_transactional_session from app.auth import current_active_user @@ -14,13 +14,16 @@ from app.models import ( List as ListModel, UserGroup as UserGroupModel, UserRoleEnum, - ExpenseSplit as ExpenseSplitModel + ExpenseSplit as ExpenseSplitModel, + Expense as ExpenseModel, + Settlement as SettlementModel ) from app.schemas.expense import ( ExpenseCreate, ExpensePublic, SettlementCreate, SettlementPublic, ExpenseUpdate, SettlementUpdate ) +from app.schemas.financials import FinancialActivityResponse from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added from app.crud import expense as crud_expense from app.crud import settlement as crud_settlement @@ -32,6 +35,7 @@ from app.core.exceptions import ( InvalidOperationError, GroupPermissionError, ListPermissionError, ItemNotFoundError, GroupMembershipError ) +from app.services import financials_service logger = logging.getLogger(__name__) router = APIRouter() @@ -655,4 +659,21 @@ async def delete_settlement_record( logger.error(f"Unexpected error deleting settlement {settlement_id}: {str(e)}", exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") - return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status_code=status.HTTP_204_NO_CONTENT) + +@router.get("/users/me/financial-activity", response_model=FinancialActivityResponse, summary="Get User's Financial Activity", tags=["Users", "Expenses", "Settlements"]) +async def get_user_financial_activity( + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """ + Retrieves a consolidated and chronologically sorted list of all financial activities + for the current user, including expenses they are part of and settlements they have + made or received. + """ + logger.info(f"User {current_user.email} requesting their financial activity feed.") + activities = await financials_service.get_user_financial_activity(db=db, user_id=current_user.id) + + # The service returns a mix of ExpenseModel and SettlementModel objects. + # We need to wrap it in our response schema. Pydantic will handle the Union type. + return FinancialActivityResponse(activities=activities) \ No newline at end of file diff --git a/be/app/core/exceptions.py b/be/app/core/exceptions.py index 0b5f936..fb12a62 100644 --- a/be/app/core/exceptions.py +++ b/be/app/core/exceptions.py @@ -258,14 +258,22 @@ class InviteOperationError(HTTPException): class SettlementOperationError(HTTPException): """Raised when a settlement operation fails.""" - def __init__(self, detail: str): + def __init__(self, detail: str = "An error occurred during a settlement operation."): super().__init__( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail ) +class FinancialConflictError(HTTPException): + """Raised when a financial conflict occurs.""" + def __init__(self, detail: str): + super().__init__( + status_code=status.HTTP_409_CONFLICT, + detail=detail + ) + class ConflictError(HTTPException): - """Raised when an optimistic lock version conflict occurs.""" + """Raised when a conflict occurs.""" def __init__(self, detail: str): super().__init__( status_code=status.HTTP_409_CONFLICT, diff --git a/be/app/crud/audit.py b/be/app/crud/audit.py index 5165722..bc842b5 100644 --- a/be/app/crud/audit.py +++ b/be/app/crud/audit.py @@ -22,8 +22,7 @@ async def create_financial_audit_log( ) log_entry = FinancialAuditLog(**log_entry_data.dict()) db.add(log_entry) - await db.commit() - await db.refresh(log_entry) + await db.flush() return log_entry async def get_financial_audit_logs_for_group(db: AsyncSession, *, group_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]: diff --git a/be/app/crud/settlement_activity.py b/be/app/crud/settlement_activity.py index 7c51bc2..bf55f7a 100644 --- a/be/app/crud/settlement_activity.py +++ b/be/app/crud/settlement_activity.py @@ -16,6 +16,8 @@ from app.models import ( ) from pydantic import BaseModel from app.crud.audit import create_financial_audit_log +from app.schemas.settlement_activity import SettlementActivityCreate +from app.core.exceptions import UserNotFoundError, InvalidOperationError, FinancialConflictError class SettlementActivityCreatePlaceholder(BaseModel): @@ -114,21 +116,34 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op async def create_settlement_activity( db: AsyncSession, - settlement_activity_in: SettlementActivityCreatePlaceholder, + settlement_activity_in: SettlementActivityCreate, current_user_id: int -) -> Optional[SettlementActivity]: +) -> SettlementActivity: """ Creates a new settlement activity, then updates the parent expense split and expense statuses. + Uses pessimistic locking on the ExpenseSplit row to prevent race conditions. + Relies on the calling context (e.g., transactional session dependency) for the transaction. """ - split_result = await db.execute(select(ExpenseSplit).where(ExpenseSplit.id == settlement_activity_in.expense_split_id)) + # Lock the expense split row for the duration of the transaction + split_stmt = ( + select(ExpenseSplit) + .where(ExpenseSplit.id == settlement_activity_in.expense_split_id) + .with_for_update() + ) + split_result = await db.execute(split_stmt) expense_split = split_result.scalar_one_or_none() + if not expense_split: - return None + raise InvalidOperationError(f"Expense split with ID {settlement_activity_in.expense_split_id} not found.") + # Check if the split is already fully paid + if expense_split.status == ExpenseSplitStatusEnum.paid: + raise FinancialConflictError(f"Expense split {expense_split.id} is already fully paid.") + + # Validate that the user paying exists user_result = await db.execute(select(User).where(User.id == settlement_activity_in.paid_by_user_id)) - paid_by_user = user_result.scalar_one_or_none() - if not paid_by_user: - return None # User not found + if not user_result.scalar_one_or_none(): + raise UserNotFoundError(user_id=settlement_activity_in.paid_by_user_id) db_settlement_activity = SettlementActivity( expense_split_id=settlement_activity_in.expense_split_id, @@ -148,14 +163,28 @@ async def create_settlement_activity( entity=db_settlement_activity, ) - # Update statuses + # Update statuses within the same transaction updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id) if updated_split and updated_split.expense_id: await update_expense_overall_status(db, expense_id=updated_split.expense_id) - else: - pass - return db_settlement_activity + # Re-fetch the object with all relationships loaded to prevent lazy-loading issues during serialization + stmt = ( + select(SettlementActivity) + .where(SettlementActivity.id == db_settlement_activity.id) + .options( + selectinload(SettlementActivity.payer), + selectinload(SettlementActivity.creator) + ) + ) + result = await db.execute(stmt) + loaded_activity = result.scalar_one_or_none() + + if not loaded_activity: + # This should not happen in a normal flow + raise InvalidOperationError("Failed to load settlement activity after creation.") + + return loaded_activity async def get_settlement_activity_by_id( diff --git a/be/app/jobs/recurring_expenses.py b/be/app/jobs/recurring_expenses.py index c0328a9..4e47652 100644 --- a/be/app/jobs/recurring_expenses.py +++ b/be/app/jobs/recurring_expenses.py @@ -1,9 +1,10 @@ from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ -from app.models import Expense, RecurrencePattern +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 +from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate import logging from typing import Optional @@ -29,6 +30,8 @@ async def generate_recurring_expenses(db: AsyncSession) -> None: (RecurrencePattern.end_date > now) ) ) + ).options( + selectinload(Expense.splits) # Eager load splits to use as a template ) result = await db.execute(query) @@ -49,11 +52,31 @@ 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, @@ -65,18 +88,28 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None: group_id=expense.group_id, item_id=expense.item_id, paid_by_user_id=expense.paid_by_user_id, - is_recurring=False, - splits_in=None + 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 - expense.next_occurrence = _calculate_next_occurrence(next_date, pattern) + next_next_date = _calculate_next_occurrence(next_date, pattern) - if pattern.max_occurrences: + # 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]: @@ -84,27 +117,46 @@ def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePatter if not current_date: return None + next_date = None if pattern.type == 'daily': - return current_date + timedelta(days=pattern.interval) + next_date = current_date + timedelta(days=pattern.interval) elif pattern.type == 'weekly': if not pattern.days_of_week: - return current_date + timedelta(weeks=pattern.interval) - - current_weekday = current_date.weekday() - next_weekday = min((d for d in pattern.days_of_week if d > current_weekday), - default=min(pattern.days_of_week)) - days_ahead = next_weekday - current_weekday - if days_ahead <= 0: - days_ahead += 7 - return current_date + timedelta(days=days_ahead) - + 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 - return current_date.replace(year=year, month=month) - + # 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': - return current_date.replace(year=current_date.year + pattern.interval) + 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 None \ No newline at end of file + return next_date \ No newline at end of file diff --git a/be/app/schemas/expense.py b/be/app/schemas/expense.py index 7b26f47..4383f16 100644 --- a/be/app/schemas/expense.py +++ b/be/app/schemas/expense.py @@ -5,12 +5,14 @@ from datetime import datetime from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum from app.schemas.user import UserPublic from app.schemas.settlement_activity import SettlementActivityPublic +from app.schemas.recurrence import RecurrencePatternCreate, RecurrencePatternPublic class ExpenseSplitBase(BaseModel): user_id: int - owed_amount: Decimal + owed_amount: Optional[Decimal] = None share_percentage: Optional[Decimal] = None share_units: Optional[int] = None + # Note: Status is handled by the backend, not in create/update payloads class ExpenseSplitCreate(ExpenseSplitBase): pass @@ -18,10 +20,10 @@ class ExpenseSplitCreate(ExpenseSplitBase): class ExpenseSplitPublic(ExpenseSplitBase): id: int expense_id: int + status: ExpenseSplitStatusEnum user: Optional[UserPublic] = None created_at: datetime updated_at: datetime - status: ExpenseSplitStatusEnum paid_at: Optional[datetime] = None settlement_activities: List[SettlementActivityPublic] = [] model_config = ConfigDict(from_attributes=True) diff --git a/be/app/schemas/financials.py b/be/app/schemas/financials.py new file mode 100644 index 0000000..3d8cb40 --- /dev/null +++ b/be/app/schemas/financials.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel +from typing import Union, List +from .expense import ExpensePublic, SettlementPublic + +class FinancialActivityResponse(BaseModel): + activities: List[Union[ExpensePublic, SettlementPublic]] + + class Config: + orm_mode = True \ No newline at end of file diff --git a/be/app/schemas/recurrence.py b/be/app/schemas/recurrence.py new file mode 100644 index 0000000..e1b65dc --- /dev/null +++ b/be/app/schemas/recurrence.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, validator +from typing import Optional, List +from datetime import datetime + +class RecurrencePatternBase(BaseModel): + type: str + interval: int = 1 + days_of_week: Optional[List[int]] = None + end_date: Optional[datetime] = None + max_occurrences: Optional[int] = None + + @validator('type') + def type_must_be_valid(cls, v): + if v not in ['daily', 'weekly', 'monthly', 'yearly']: + raise ValueError("type must be one of 'daily', 'weekly', 'monthly', 'yearly'") + return v + + @validator('days_of_week') + def days_of_week_must_be_valid(cls, v): + if v: + for day in v: + if not 0 <= day <= 6: + raise ValueError("days_of_week must be between 0 and 6") + return v + +class RecurrencePatternCreate(RecurrencePatternBase): + pass + +class RecurrencePatternPublic(RecurrencePatternBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True \ No newline at end of file diff --git a/be/app/services/costs_service.py b/be/app/services/costs_service.py new file mode 100644 index 0000000..df74adf --- /dev/null +++ b/be/app/services/costs_service.py @@ -0,0 +1,343 @@ +# be/app/services/costs_service.py +import logging +from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN +from typing import List + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models import ( + User as UserModel, + Group as GroupModel, + List as ListModel, + Expense as ExpenseModel, + Item as ItemModel, + UserGroup as UserGroupModel, + SplitTypeEnum, + ExpenseSplit as ExpenseSplitModel, + SettlementActivity as SettlementActivityModel, + Settlement as SettlementModel +) +from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement +from app.schemas.expense import ExpenseCreate, ExpensePublic +from app.crud import list as crud_list +from app.crud import expense as crud_expense +from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError, GroupPermissionError, InvalidOperationError + +logger = logging.getLogger(__name__) + + +def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> List[SuggestedSettlement]: + """ + Calculate suggested settlements to balance the finances within a group. + + This function takes the current balances of all users and suggests optimal settlements + to minimize the number of transactions needed to settle all debts. + + Args: + user_balances: List of UserBalanceDetail objects with their current balances + + Returns: + List of SuggestedSettlement objects representing the suggested payments + """ + debtors = [] + creditors = [] + epsilon = Decimal('0.01') + + for user in user_balances: + if abs(user.net_balance) < epsilon: + continue + + if user.net_balance < Decimal('0'): + debtors.append({ + 'user_id': user.user_id, + 'user_identifier': user.user_identifier, + 'amount': -user.net_balance + }) + else: + creditors.append({ + 'user_id': user.user_id, + 'user_identifier': user.user_identifier, + 'amount': user.net_balance + }) + + debtors.sort(key=lambda x: x['amount'], reverse=True) + creditors.sort(key=lambda x: x['amount'], reverse=True) + + settlements = [] + + while debtors and creditors: + debtor = debtors[0] + creditor = creditors[0] + + amount = min(debtor['amount'], creditor['amount']).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + if amount > Decimal('0'): + settlements.append( + SuggestedSettlement( + from_user_id=debtor['user_id'], + from_user_identifier=debtor['user_identifier'], + to_user_id=creditor['user_id'], + to_user_identifier=creditor['user_identifier'], + amount=amount + ) + ) + + debtor['amount'] -= amount + creditor['amount'] -= amount + + if debtor['amount'] < epsilon: + debtors.pop(0) + if creditor['amount'] < epsilon: + creditors.pop(0) + + return settlements + + +async def get_list_cost_summary_logic( + db: AsyncSession, list_id: int, current_user_id: int +) -> ListCostSummary: + """ + Core logic to retrieve a calculated cost summary for a specific list. + This version does NOT create an expense if one is not found. + """ + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user_id) + + list_result = await db.execute( + select(ListModel) + .options( + selectinload(ListModel.items).options(selectinload(ItemModel.added_by_user)), + selectinload(ListModel.group).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))), + selectinload(ListModel.creator) + ) + .where(ListModel.id == list_id) + ) + db_list = list_result.scalars().first() + if not db_list: + raise ListNotFoundError(list_id) + + expense_result = await db.execute( + select(ExpenseModel) + .where(ExpenseModel.list_id == list_id) + .options(selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user))) + ) + db_expense = expense_result.scalars().first() + + total_list_cost = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0")) + + # If no expense exists or no items with cost, return a summary based on item prices alone. + if not db_expense: + return ListCostSummary( + list_id=db_list.id, + list_name=db_list.name, + total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + num_participating_users=0, + equal_share_per_user=Decimal("0.00"), + user_balances=[] + ) + + # --- Calculation logic based on existing expense --- + participating_users = set() + user_items_added_value = {} + + for item in db_list.items: + if item.price is not None and item.price > Decimal("0") and item.added_by_user: + participating_users.add(item.added_by_user) + user_items_added_value[item.added_by_user.id] = user_items_added_value.get(item.added_by_user.id, Decimal("0.00")) + item.price + + for split in db_expense.splits: + if split.user: + participating_users.add(split.user) + + num_participating_users = len(participating_users) + if num_participating_users == 0: + return ListCostSummary( + list_id=db_list.id, + list_name=db_list.name, + total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + num_participating_users=0, + equal_share_per_user=Decimal("0.00"), + user_balances=[] + ) + + equal_share_per_user_for_response = (db_expense.total_amount / Decimal(num_participating_users)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + sorted_participating_users = sorted(list(participating_users), key=lambda u: u.id) + user_final_shares = {} + + if num_participating_users > 0: + base_share_unrounded = db_expense.total_amount / Decimal(num_participating_users) + for user in sorted_participating_users: + user_final_shares[user.id] = base_share_unrounded.quantize(Decimal("0.01"), rounding=ROUND_DOWN) + + sum_of_rounded_shares = sum(user_final_shares.values()) + remaining_pennies = int(((db_expense.total_amount - sum_of_rounded_shares) * Decimal("100")).to_integral_value(rounding=ROUND_HALF_UP)) + + for i in range(remaining_pennies): + user_to_adjust = sorted_participating_users[i % num_participating_users] + user_final_shares[user_to_adjust.id] += Decimal("0.01") + + user_balances = [] + for user in sorted_participating_users: + items_added = user_items_added_value.get(user.id, Decimal("0.00")) + current_user_share = user_final_shares.get(user.id, Decimal("0.00")) + balance = items_added - current_user_share + user_identifier = user.name if user.name else user.email + user_balances.append( + UserCostShare( + user_id=user.id, + user_identifier=user_identifier, + items_added_value=items_added.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + amount_due=current_user_share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + balance=balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + ) + + user_balances.sort(key=lambda x: x.user_identifier) + return ListCostSummary( + list_id=db_list.id, + list_name=db_list.name, + total_list_cost=db_expense.total_amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + num_participating_users=num_participating_users, + equal_share_per_user=equal_share_per_user_for_response, + user_balances=user_balances + ) + + +async def generate_expense_from_list_logic(db: AsyncSession, list_id: int, current_user_id: int) -> ExpenseModel: + """ + Generates and saves an ITEM_BASED expense from a list's items. + """ + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user_id) + + # Check if an expense already exists for this list + existing_expense_result = await db.execute( + select(ExpenseModel).where(ExpenseModel.list_id == list_id) + ) + if existing_expense_result.scalars().first(): + raise InvalidOperationError(f"An expense already exists for list {list_id}.") + + db_list = await db.get(ListModel, list_id, options=[selectinload(ListModel.items), selectinload(ListModel.creator)]) + if not db_list: + raise ListNotFoundError(list_id) + + total_amount = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0")) + if total_amount <= Decimal("0"): + raise InvalidOperationError("Cannot create an expense for a list with no priced items.") + + expense_in = ExpenseCreate( + description=f"Cost summary for list {db_list.name}", + total_amount=total_amount, + list_id=list_id, + split_type=SplitTypeEnum.ITEM_BASED, + paid_by_user_id=db_list.creator.id + ) + return await crud_expense.create_expense(db=db, expense_in=expense_in, current_user_id=current_user_id) + + +async def get_group_balance_summary_logic( + db: AsyncSession, group_id: int, current_user_id: int +) -> GroupBalanceSummary: + """ + Core logic to retrieve a detailed financial balance summary for a group. + """ + group_check_result = await db.execute( + select(GroupModel).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))) + .where(GroupModel.id == group_id) + ) + db_group = group_check_result.scalars().first() + + if not db_group: + raise GroupNotFoundError(group_id) + + if not any(assoc.user_id == current_user_id for assoc in db_group.member_associations): + raise GroupPermissionError(group_id, "view balance summary for") + + expenses_result = await db.execute( + select(ExpenseModel).where(ExpenseModel.group_id == group_id) + .options(selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user)) + ) + expenses = expenses_result.scalars().all() + + settlements_result = await db.execute( + select(SettlementModel).where(SettlementModel.group_id == group_id) + .options(selectinload(SettlementModel.paid_by_user), selectinload(SettlementModel.paid_to_user)) + ) + settlements = settlements_result.scalars().all() + + settlement_activities_result = await db.execute( + select(SettlementActivityModel) + .join(ExpenseSplitModel, SettlementActivityModel.expense_split_id == ExpenseSplitModel.id) + .join(ExpenseModel, ExpenseSplitModel.expense_id == ExpenseModel.id) + .where(ExpenseModel.group_id == group_id) + .options(selectinload(SettlementActivityModel.payer)) + ) + settlement_activities = settlement_activities_result.scalars().all() + + user_balances_data = {} + for assoc in db_group.member_associations: + if assoc.user: + user_balances_data[assoc.user.id] = { + "user_id": assoc.user.id, + "user_identifier": assoc.user.name if assoc.user.name else assoc.user.email, + "total_paid_for_expenses": Decimal("0.00"), + "initial_total_share_of_expenses": Decimal("0.00"), + "total_amount_paid_via_settlement_activities": Decimal("0.00"), + "total_generic_settlements_paid": Decimal("0.00"), + "total_generic_settlements_received": Decimal("0.00"), + } + + for expense in expenses: + if expense.paid_by_user_id in user_balances_data: + user_balances_data[expense.paid_by_user_id]["total_paid_for_expenses"] += expense.total_amount + for split in expense.splits: + if split.user_id in user_balances_data: + user_balances_data[split.user_id]["initial_total_share_of_expenses"] += split.owed_amount + + for activity in settlement_activities: + if activity.paid_by_user_id in user_balances_data: + user_balances_data[activity.paid_by_user_id]["total_amount_paid_via_settlement_activities"] += activity.amount_paid + + for settlement in settlements: + if settlement.paid_by_user_id in user_balances_data: + user_balances_data[settlement.paid_by_user_id]["total_generic_settlements_paid"] += settlement.amount + if settlement.paid_to_user_id in user_balances_data: + user_balances_data[settlement.paid_to_user_id]["total_generic_settlements_received"] += settlement.amount + + final_user_balances = [] + for user_id, data in user_balances_data.items(): + initial_total_share_of_expenses = data["initial_total_share_of_expenses"] + total_amount_paid_via_settlement_activities = data["total_amount_paid_via_settlement_activities"] + adjusted_total_share_of_expenses = initial_total_share_of_expenses - total_amount_paid_via_settlement_activities + total_paid_for_expenses = data["total_paid_for_expenses"] + total_generic_settlements_received = data["total_generic_settlements_received"] + total_generic_settlements_paid = data["total_generic_settlements_paid"] + net_balance = ( + total_paid_for_expenses + total_generic_settlements_received + ) - (adjusted_total_share_of_expenses + total_generic_settlements_paid) + + user_detail = UserBalanceDetail( + user_id=data["user_id"], + user_identifier=data["user_identifier"], + total_paid_for_expenses=total_paid_for_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + total_share_of_expenses=adjusted_total_share_of_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + total_settlements_paid=total_generic_settlements_paid.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + total_settlements_received=total_generic_settlements_received.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + net_balance=net_balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + final_user_balances.append(user_detail) + + final_user_balances.sort(key=lambda x: x.user_identifier) + suggested_settlements = calculate_suggested_settlements(final_user_balances) + overall_total_expenses = sum(expense.total_amount for expense in expenses) + overall_total_settlements = sum(settlement.amount for settlement in settlements) + + return GroupBalanceSummary( + group_id=db_group.id, + group_name=db_group.name, + overall_total_expenses=overall_total_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + overall_total_settlements=overall_total_settlements.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + user_balances=final_user_balances, + suggested_settlements=suggested_settlements + ) \ No newline at end of file diff --git a/be/app/services/financials_service.py b/be/app/services/financials_service.py new file mode 100644 index 0000000..3c089f9 --- /dev/null +++ b/be/app/services/financials_service.py @@ -0,0 +1,31 @@ +import logging +from typing import List, Union +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from app.models import Expense as ExpenseModel, Settlement as SettlementModel +from app.crud import expense as crud_expense, settlement as crud_settlement + +logger = logging.getLogger(__name__) + +async def get_user_financial_activity( + db: AsyncSession, user_id: int +) -> List[Union[ExpenseModel, SettlementModel]]: + """ + Retrieves and merges all financial activities (expenses and settlements) for a user. + The combined list is sorted by date. + """ + # Fetch all accessible expenses + expenses = await crud_expense.get_user_accessible_expenses(db, user_id=user_id, limit=200) # Using a generous limit + + # Fetch all settlements involving the user + settlements = await crud_settlement.get_settlements_involving_user(db, user_id=user_id, limit=200) # Using a generous limit + + # Combine and sort the activities + # We use a lambda to get the primary date for sorting from either type of object + combined_activity = sorted( + expenses + settlements, + key=lambda x: x.expense_date if isinstance(x, ExpenseModel) else x.settlement_date, + reverse=True + ) + + return combined_activity \ No newline at end of file diff --git a/fe/src/pages/ListsPage.vue b/fe/src/pages/ListsPage.vue index 1051cbe..feaddd8 100644 --- a/fe/src/pages/ListsPage.vue +++ b/fe/src/pages/ListsPage.vue @@ -32,11 +32,11 @@ @touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">