from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from typing import List, Optional from sqlalchemy.orm import Session from sqlalchemy import text from app.core.database import get_db from app.core.auth import check_trip_auth from app.schemas.expense import ExpenseCreate, ExpenseResponse from app.models.expense import Expense, ExpenseSplit from app.models.trip import Trip from app.models.participant import Participant router = APIRouter() @router.post("/{trip_id}/expenses", response_model=ExpenseResponse) async def create_expense( trip_id: int, expense: ExpenseCreate, db: Session = Depends(get_db), credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)) ): """Create a new expense with splits (requires authentication if trip has password)""" # Check trip authentication trip = check_trip_auth(trip_id, credentials, db) # Validate splits sum to 100% (allow 98-100.01% to account for rounding down in equal splits) total_percentage = sum(split.percentage for split in expense.splits) if total_percentage < 98 or total_percentage > 100.01: # Accept 98-100.01% for equal splits (e.g., 33% x 3 = 99%) raise HTTPException(status_code=400, detail=f"Splits must sum to 100%. Current total: {total_percentage:.2f}%") # Verify all participants exist and belong to this trip participant_ids = [split.participant_id for split in expense.splits] participants = db.query(Participant).filter( Participant.id.in_(participant_ids), Participant.trip_id == trip_id, Participant.is_active == True ).all() if len(participants) != len(participant_ids): raise HTTPException(status_code=400, detail="Invalid participants") # Verify paid_by participant exists paid_by = db.query(Participant).filter( Participant.id == expense.paid_by_id, Participant.trip_id == trip_id, Participant.is_active == True ).first() if not paid_by: raise HTTPException(status_code=400, detail="Invalid paid_by participant") # Create expense db_expense = Expense( trip_id=trip_id, description=expense.description, amount=expense.amount, currency_code=expense.currency_code, exchange_rate=expense.exchange_rate, category=expense.category, receipt_url=expense.receipt_url, paid_by_id=expense.paid_by_id, date_incurred=datetime.utcnow() ) db.add(db_expense) db.commit() db.refresh(db_expense) # Create expense splits splits = [] for split_data in expense.splits: # Calculate amount in trip currency (convert first, then apply percentage) amount_in_trip_currency = (expense.amount * expense.exchange_rate) * (split_data.percentage / 100.0) db_split = ExpenseSplit( expense_id=db_expense.id, participant_id=split_data.participant_id, percentage=split_data.percentage, amount=amount_in_trip_currency ) db.add(db_split) splits.append(db_split) db.commit() # Refresh to get all data db.refresh(db_expense) # Build response with joined data return build_expense_response(db_expense, db) @router.get("/{trip_id}/expenses", response_model=List[ExpenseResponse]) async def get_expenses( trip_id: int, db: Session = Depends(get_db), credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)) ): """Get all expenses for a trip (requires authentication if trip has password)""" # Check trip authentication trip = check_trip_auth(trip_id, credentials, db) expenses = db.query(Expense).filter(Expense.trip_id == trip_id).order_by(Expense.created_at.desc()).all() return [build_expense_response(expense, db) for expense in expenses] @router.get("/{trip_id}/expenses/{expense_id}", response_model=ExpenseResponse) async def get_expense( trip_id: int, expense_id: int, db: Session = Depends(get_db), credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)) ): """Get a specific expense (requires authentication if trip has password)""" # Check trip authentication trip = check_trip_auth(trip_id, credentials, db) expense = db.query(Expense).filter( Expense.id == expense_id, Expense.trip_id == trip_id ).first() if not expense: raise HTTPException(status_code=404, detail="Expense not found") return build_expense_response(expense, db) def build_expense_response(expense: Expense, db: Session) -> ExpenseResponse: """Build expense response with joined data""" # Get paid_by name paid_by = db.query(Participant).filter(Participant.id == expense.paid_by_id).first() # Get splits with participant names splits = db.query(ExpenseSplit, Participant).join( Participant, ExpenseSplit.participant_id == Participant.id ).filter(ExpenseSplit.expense_id == expense.id).all() split_responses = [] for split, participant in splits: split_responses.append({ "id": split.id, "expense_id": split.expense_id, "participant_id": split.participant_id, "participant_name": participant.name, "percentage": split.percentage, "amount": split.amount, "is_settled": split.is_settled, "created_at": split.created_at }) return { "id": expense.id, "trip_id": expense.trip_id, "description": expense.description, "amount": expense.amount, "currency_code": expense.currency_code, "exchange_rate": expense.exchange_rate, "amount_in_trip_currency": expense.amount * expense.exchange_rate, "category": expense.category, "receipt_url": expense.receipt_url, "paid_by_id": expense.paid_by_id, "paid_by_name": paid_by.name if paid_by else "Unknown", "date_incurred": expense.date_incurred, "created_at": expense.created_at, "splits": split_responses } @router.put("/expenses/{expense_id}", response_model=ExpenseResponse) async def update_expense( expense_id: int, expense: ExpenseCreate, db: Session = Depends(get_db), credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)) ): """Update an existing expense with splits (requires authentication if trip has password)""" # Check if expense exists existing_expense = db.query(Expense).filter(Expense.id == expense_id).first() if not existing_expense: raise HTTPException(status_code=404, detail="Expense not found") # Check trip authentication trip = check_trip_auth(existing_expense.trip_id, credentials, db) # Validate splits sum to 100% (allow 98-100.01% to account for rounding down in equal splits) total_percentage = sum(split.percentage for split in expense.splits) if total_percentage < 98 or total_percentage > 100.01: # Accept 98-100.01% for equal splits (e.g., 33% x 3 = 99%) raise HTTPException(status_code=400, detail=f"Splits must sum to 100%. Current total: {total_percentage:.2f}%") # Verify all participants exist and belong to this trip participant_ids = [split.participant_id for split in expense.splits] participants = db.query(Participant).filter( Participant.id.in_(participant_ids), Participant.trip_id == existing_expense.trip_id, Participant.is_active == True ).all() if len(participants) != len(participant_ids): raise HTTPException(status_code=400, detail="Invalid participants") # Verify paid_by participant exists paid_by = db.query(Participant).filter( Participant.id == expense.paid_by_id, Participant.trip_id == existing_expense.trip_id, Participant.is_active == True ).first() if not paid_by: raise HTTPException(status_code=400, detail="Invalid paid_by participant") # Update expense details existing_expense.description = expense.description existing_expense.amount = expense.amount existing_expense.currency_code = expense.currency_code existing_expense.exchange_rate = expense.exchange_rate existing_expense.category = expense.category existing_expense.paid_by_id = expense.paid_by_id # Use provided date_incurred or default to current time existing_expense.date_incurred = expense.date_incurred if expense.date_incurred else datetime.utcnow() # Delete existing splits db.query(ExpenseSplit).filter(ExpenseSplit.expense_id == expense_id).delete() # Create new splits for split_data in expense.splits: # Calculate amount in trip currency for this split amount_in_trip_currency = (expense.amount * expense.exchange_rate) * (split_data.percentage / 100) expense_split = ExpenseSplit( expense_id=existing_expense.id, participant_id=split_data.participant_id, percentage=split_data.percentage, amount=amount_in_trip_currency, is_settled=False ) db.add(expense_split) db.commit() db.refresh(existing_expense) return build_expense_response(existing_expense, db)