mitlist/be/app/api/v1/endpoints/financials.py

439 lines
23 KiB
Python

# 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)