# app/api/v1/endpoints/financials.py import logging from fastapi import APIRouter, Depends, HTTPException, status, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import List as PyList, Optional, Sequence from app.database import get_db from app.auth import current_active_user from app.models import User as UserModel, Group as GroupModel, List as ListModel, UserGroup as UserGroupModel, UserRoleEnum from app.schemas.expense import ( ExpenseCreate, ExpensePublic, SettlementCreate, SettlementPublic, ExpenseUpdate, SettlementUpdate ) from app.crud import expense as crud_expense from app.crud import settlement as crud_settlement from app.crud import group as crud_group from app.crud import list as crud_list from app.core.exceptions import ( ListNotFoundError, GroupNotFoundError, UserNotFoundError, InvalidOperationError, GroupPermissionError, ListPermissionError, ItemNotFoundError, GroupMembershipError ) logger = logging.getLogger(__name__) router = APIRouter() # --- Helper for permissions --- async def check_list_access_for_financials(db: AsyncSession, list_id: int, user_id: int, action: str = "access financial data for"): try: await crud_list.check_list_permission(db=db, list_id=list_id, user_id=user_id, require_member=True) except ListPermissionError as e: logger.warning(f"ListPermissionError in check_list_access_for_financials for list {list_id}, user {user_id}, action '{action}': {e.detail}") raise ListPermissionError(list_id, action=action) except ListNotFoundError: raise # --- Expense Endpoints --- @router.post( "/expenses", response_model=ExpensePublic, status_code=status.HTTP_201_CREATED, summary="Create New Expense", tags=["Expenses"] ) async def create_new_expense( expense_in: ExpenseCreate, db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} creating expense: {expense_in.description}") effective_group_id = expense_in.group_id is_group_context = False if expense_in.list_id: # Check basic access to list (implies membership if list is in group) await check_list_access_for_financials(db, expense_in.list_id, current_user.id, action="create expenses for") list_obj = await db.get(ListModel, expense_in.list_id) if not list_obj: raise ListNotFoundError(expense_in.list_id) if list_obj.group_id: if expense_in.group_id and list_obj.group_id != expense_in.group_id: raise InvalidOperationError(f"List {list_obj.id} belongs to group {list_obj.group_id}, not group {expense_in.group_id} specified in expense.") effective_group_id = list_obj.group_id is_group_context = True # Expense is tied to a group via the list elif expense_in.group_id: raise InvalidOperationError(f"Personal list {list_obj.id} cannot have expense associated with group {expense_in.group_id}.") # If list is personal, no group check needed yet, handled by payer check below. elif effective_group_id: # Only group_id provided for expense is_group_context = True # Ensure user is at least a member to create expense in group context await crud_group.check_group_membership(db, group_id=effective_group_id, user_id=current_user.id, action="create expenses for") else: # This case should ideally be caught by earlier checks if list_id was present but list was personal. # If somehow reached, it means no list_id and no group_id. raise InvalidOperationError("Expense must be linked to a list_id or group_id.") # Finalize expense payload with correct group_id if derived expense_in_final = expense_in.model_copy(update={"group_id": effective_group_id}) # --- Granular Permission Check for Payer --- if expense_in_final.paid_by_user_id != current_user.id: logger.warning(f"User {current_user.email} attempting to create expense paid by other user {expense_in_final.paid_by_user_id}") # If creating expense paid by someone else, user MUST be owner IF in group context if is_group_context and effective_group_id: try: await crud_group.check_user_role_in_group(db, group_id=effective_group_id, user_id=current_user.id, required_role=UserRoleEnum.owner, action="create expense paid by another user") except GroupPermissionError as e: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Only group owners can create expenses paid by others. {str(e)}") else: # Cannot create expense paid by someone else for a personal list (no group context) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot create expense paid by another user for a personal list.") # If paying for self, basic list/group membership check above is sufficient. try: created_expense = await crud_expense.create_expense(db=db, expense_in=expense_in_final, current_user_id=current_user.id) logger.info(f"Expense '{created_expense.description}' (ID: {created_expense.id}) created successfully.") return created_expense except (UserNotFoundError, ListNotFoundError, GroupNotFoundError, InvalidOperationError, GroupMembershipError) as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except NotImplementedError as e: raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(e)) except Exception as e: logger.error(f"Unexpected error creating expense: {str(e)}", exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") @router.get("/expenses/{expense_id}", response_model=ExpensePublic, summary="Get Expense by ID", tags=["Expenses"]) async def get_expense( expense_id: int, db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} requesting expense ID {expense_id}") expense = await crud_expense.get_expense_by_id(db, expense_id=expense_id) if not expense: raise ItemNotFoundError(item_id=expense_id) if expense.list_id: await check_list_access_for_financials(db, expense.list_id, current_user.id) elif expense.group_id: await crud_group.check_group_membership(db, group_id=expense.group_id, user_id=current_user.id) elif expense.paid_by_user_id != current_user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to view this expense") return expense @router.get("/lists/{list_id}/expenses", response_model=PyList[ExpensePublic], summary="List Expenses for a List", tags=["Expenses", "Lists"]) async def list_list_expenses( list_id: int, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} listing expenses for list ID {list_id}") await check_list_access_for_financials(db, list_id, current_user.id) expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) return expenses @router.get("/groups/{group_id}/expenses", response_model=PyList[ExpensePublic], summary="List Expenses for a Group", tags=["Expenses", "Groups"]) async def list_group_expenses( group_id: int, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} listing expenses for group ID {group_id}") await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list expenses for") expenses = await crud_expense.get_expenses_for_group(db, group_id=group_id, skip=skip, limit=limit) return expenses @router.put("/expenses/{expense_id}", response_model=ExpensePublic, summary="Update Expense", tags=["Expenses"]) async def update_expense_details( expense_id: int, expense_in: ExpenseUpdate, db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): """ Updates an existing expense (description, currency, expense_date only). Requires the current version number for optimistic locking. User must have permission to modify the expense (e.g., be the payer or group admin). """ logger.info(f"User {current_user.email} attempting to update expense ID {expense_id} (version {expense_in.version})") expense_db = await crud_expense.get_expense_by_id(db, expense_id=expense_id) if not expense_db: raise ItemNotFoundError(item_id=expense_id) # --- Granular Permission Check --- can_modify = False # 1. User paid for the expense if expense_db.paid_by_user_id == current_user.id: can_modify = True # 2. OR User is owner of the group the expense belongs to elif expense_db.group_id: try: await crud_group.check_user_role_in_group(db, group_id=expense_db.group_id, user_id=current_user.id, required_role=UserRoleEnum.owner, action="modify group expenses") can_modify = True logger.info(f"Allowing update for expense {expense_id} by group owner {current_user.email}") except GroupMembershipError: # User not even a member pass # Keep can_modify as False except GroupPermissionError: # User is member but not owner pass # Keep can_modify as False except GroupNotFoundError: # Group doesn't exist (data integrity issue) logger.error(f"Group {expense_db.group_id} not found for expense {expense_id} during update check.") pass # Keep can_modify as False # Note: If expense is only linked to a personal list (no group), only payer can modify. if not can_modify: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User cannot modify this expense (must be payer or group owner)") try: updated_expense = await crud_expense.update_expense(db=db, expense_db=expense_db, expense_in=expense_in) logger.info(f"Expense ID {expense_id} updated successfully to version {updated_expense.version}.") return updated_expense except InvalidOperationError as e: # Check if it's a version conflict (409) or other validation error (400) status_code = status.HTTP_400_BAD_REQUEST if "version" in str(e).lower(): status_code = status.HTTP_409_CONFLICT raise HTTPException(status_code=status_code, detail=str(e)) except Exception as e: logger.error(f"Unexpected error updating expense {expense_id}: {str(e)}", exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") @router.delete("/expenses/{expense_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete Expense", tags=["Expenses"]) async def delete_expense_record( expense_id: int, expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"), db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): """ Deletes an expense and its associated splits. Requires expected_version query parameter for optimistic locking. User must have permission to delete the expense (e.g., be the payer or group admin). """ logger.info(f"User {current_user.email} attempting to delete expense ID {expense_id} (expected version {expected_version})") expense_db = await crud_expense.get_expense_by_id(db, expense_id=expense_id) if not expense_db: # Return 204 even if not found, as the end state is achieved (item is gone) logger.warning(f"Attempt to delete non-existent expense ID {expense_id}") return Response(status_code=status.HTTP_204_NO_CONTENT) # Alternatively, raise NotFoundError(detail=f"Expense {expense_id} not found") -> 404 # --- Granular Permission Check --- can_delete = False # 1. User paid for the expense if expense_db.paid_by_user_id == current_user.id: can_delete = True # 2. OR User is owner of the group the expense belongs to elif expense_db.group_id: try: await crud_group.check_user_role_in_group(db, group_id=expense_db.group_id, user_id=current_user.id, required_role=UserRoleEnum.owner, action="delete group expenses") can_delete = True logger.info(f"Allowing delete for expense {expense_id} by group owner {current_user.email}") except GroupMembershipError: pass except GroupPermissionError: pass except GroupNotFoundError: logger.error(f"Group {expense_db.group_id} not found for expense {expense_id} during delete check.") pass if not can_delete: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User cannot delete this expense (must be payer or group owner)") try: await crud_expense.delete_expense(db=db, expense_db=expense_db, expected_version=expected_version) logger.info(f"Expense ID {expense_id} deleted successfully.") # No need to return content on 204 except InvalidOperationError as e: # Check if it's a version conflict (409) or other validation error (400) status_code = status.HTTP_400_BAD_REQUEST if "version" in str(e).lower(): status_code = status.HTTP_409_CONFLICT raise HTTPException(status_code=status_code, detail=str(e)) except Exception as e: logger.error(f"Unexpected error deleting expense {expense_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) # --- Settlement Endpoints --- @router.post( "/settlements", response_model=SettlementPublic, status_code=status.HTTP_201_CREATED, summary="Record New Settlement", tags=["Settlements"] ) async def create_new_settlement( settlement_in: SettlementCreate, db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} recording settlement in group {settlement_in.group_id}") await crud_group.check_group_membership(db, group_id=settlement_in.group_id, user_id=current_user.id, action="record settlements in") try: await crud_group.check_group_membership(db, group_id=settlement_in.group_id, user_id=settlement_in.paid_by_user_id, action="be a payer in this group's settlement") await crud_group.check_group_membership(db, group_id=settlement_in.group_id, user_id=settlement_in.paid_to_user_id, action="be a payee in this group's settlement") except GroupMembershipError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Payer or payee issue: {str(e)}") except GroupNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) try: created_settlement = await crud_settlement.create_settlement(db=db, settlement_in=settlement_in, current_user_id=current_user.id) logger.info(f"Settlement ID {created_settlement.id} recorded successfully in group {settlement_in.group_id}.") return created_settlement except (UserNotFoundError, GroupNotFoundError, InvalidOperationError, GroupMembershipError) as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: logger.error(f"Unexpected error recording settlement: {str(e)}", exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") @router.get("/settlements/{settlement_id}", response_model=SettlementPublic, summary="Get Settlement by ID", tags=["Settlements"]) async def get_settlement( settlement_id: int, db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} requesting settlement ID {settlement_id}") settlement = await crud_settlement.get_settlement_by_id(db, settlement_id=settlement_id) if not settlement: raise ItemNotFoundError(item_id=settlement_id) is_party_to_settlement = current_user.id in [settlement.paid_by_user_id, settlement.paid_to_user_id] try: await crud_group.check_group_membership(db, group_id=settlement.group_id, user_id=current_user.id) except GroupMembershipError: if not is_party_to_settlement: raise GroupMembershipError(settlement.group_id, action="view this settlement's details") logger.info(f"User {current_user.email} (party to settlement) viewing settlement {settlement_id} for group {settlement.group_id}.") return settlement @router.get("/groups/{group_id}/settlements", response_model=PyList[SettlementPublic], summary="List Settlements for a Group", tags=["Settlements", "Groups"]) async def list_group_settlements( group_id: int, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} listing settlements for group ID {group_id}") await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list settlements for this group") settlements = await crud_settlement.get_settlements_for_group(db, group_id=group_id, skip=skip, limit=limit) return settlements @router.put("/settlements/{settlement_id}", response_model=SettlementPublic, summary="Update Settlement", tags=["Settlements"]) async def update_settlement_details( settlement_id: int, settlement_in: SettlementUpdate, db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): """ Updates an existing settlement (description, settlement_date only). Requires the current version number for optimistic locking. User must have permission (e.g., be involved party or group admin). """ logger.info(f"User {current_user.email} attempting to update settlement ID {settlement_id} (version {settlement_in.version})") settlement_db = await crud_settlement.get_settlement_by_id(db, settlement_id=settlement_id) if not settlement_db: raise ItemNotFoundError(item_id=settlement_id) # --- Granular Permission Check --- can_modify = False # 1. User is involved party (payer or payee) is_party = current_user.id in [settlement_db.paid_by_user_id, settlement_db.paid_to_user_id] if is_party: can_modify = True # 2. OR User is owner of the group the settlement belongs to # Note: Settlements always have a group_id based on current model elif settlement_db.group_id: try: await crud_group.check_user_role_in_group(db, group_id=settlement_db.group_id, user_id=current_user.id, required_role=UserRoleEnum.owner, action="modify group settlements") can_modify = True logger.info(f"Allowing update for settlement {settlement_id} by group owner {current_user.email}") except GroupMembershipError: pass except GroupPermissionError: pass except GroupNotFoundError: logger.error(f"Group {settlement_db.group_id} not found for settlement {settlement_id} during update check.") pass if not can_modify: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User cannot modify this settlement (must be involved party or group owner)") try: updated_settlement = await crud_settlement.update_settlement(db=db, settlement_db=settlement_db, settlement_in=settlement_in) logger.info(f"Settlement ID {settlement_id} updated successfully to version {updated_settlement.version}.") return updated_settlement except InvalidOperationError as e: status_code = status.HTTP_400_BAD_REQUEST if "version" in str(e).lower(): status_code = status.HTTP_409_CONFLICT raise HTTPException(status_code=status_code, detail=str(e)) except Exception as e: logger.error(f"Unexpected error updating settlement {settlement_id}: {str(e)}", exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") @router.delete("/settlements/{settlement_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete Settlement", tags=["Settlements"]) async def delete_settlement_record( settlement_id: int, expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"), db: AsyncSession = Depends(get_db), current_user: UserModel = Depends(current_active_user), ): """ Deletes a settlement. Requires expected_version query parameter for optimistic locking. User must have permission (e.g., be involved party or group admin). """ logger.info(f"User {current_user.email} attempting to delete settlement ID {settlement_id} (expected version {expected_version})") settlement_db = await crud_settlement.get_settlement_by_id(db, settlement_id=settlement_id) if not settlement_db: logger.warning(f"Attempt to delete non-existent settlement ID {settlement_id}") return Response(status_code=status.HTTP_204_NO_CONTENT) # --- Granular Permission Check --- can_delete = False # 1. User is involved party (payer or payee) is_party = current_user.id in [settlement_db.paid_by_user_id, settlement_db.paid_to_user_id] if is_party: can_delete = True # 2. OR User is owner of the group the settlement belongs to elif settlement_db.group_id: try: await crud_group.check_user_role_in_group(db, group_id=settlement_db.group_id, user_id=current_user.id, required_role=UserRoleEnum.owner, action="delete group settlements") can_delete = True logger.info(f"Allowing delete for settlement {settlement_id} by group owner {current_user.email}") except GroupMembershipError: pass except GroupPermissionError: pass except GroupNotFoundError: logger.error(f"Group {settlement_db.group_id} not found for settlement {settlement_id} during delete check.") pass if not can_delete: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User cannot delete this settlement (must be involved party or group owner)") try: await crud_settlement.delete_settlement(db=db, settlement_db=settlement_db, expected_version=expected_version) logger.info(f"Settlement ID {settlement_id} deleted successfully.") except InvalidOperationError as e: status_code = status.HTTP_400_BAD_REQUEST if "version" in str(e).lower(): status_code = status.HTTP_409_CONFLICT raise HTTPException(status_code=status_code, detail=str(e)) except Exception as e: 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)