from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import List, Optional from database import get_db from models.expenses import Contact, Expense, ExpensePayment from models.user import User from schemas.expenses import ( ContactCreate, ContactUpdate, ContactOut, ExpenseCreate, ExpenseUpdate, ExpenseOut, ExpensePaymentCreate, ExpensePaymentOut, ) from routers.deps import require_manager contacts_router = APIRouter() expenses_router = APIRouter() def _utcnow(): return datetime.now(timezone.utc) def _expense_status(total: float, paid: float) -> str: if paid >= total: return "paid" if paid > 0: return "partial" return "due" def _expense_out(e: Expense) -> dict: contact_name = None if e.contact: contact_name = e.contact.name creator_name = None if e.created_by: creator_name = e.created_by.full_name or e.created_by.username due_amount = round(max(0.0, e.total_amount - e.paid_amount), 2) payments_out = [] for p in e.payments: payer_name = None if p.paid_by: payer_name = p.paid_by.full_name or p.paid_by.username payments_out.append({ "id": p.id, "expense_id": p.expense_id, "amount": p.amount, "paid_at": p.paid_at, "paid_by_id": p.paid_by_id, "paid_by_name": payer_name, "notes": p.notes, }) return { "id": e.id, "description": e.description, "category": e.category, "contact_id": e.contact_id, "contact_name": contact_name, "total_amount": e.total_amount, "paid_amount": e.paid_amount, "due_amount": due_amount, "status": _expense_status(e.total_amount, e.paid_amount), "due_date": e.due_date, "business_day_id": e.business_day_id, "created_by_id": e.created_by_id, "created_by_name": creator_name, "created_at": e.created_at, "notes": e.notes, "payments": payments_out, } # ── Contacts ────────────────────────────────────────────────────────────────── @contacts_router.get("/", response_model=List[ContactOut]) def list_contacts( include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager), ): q = db.query(Contact) if not include_inactive: q = q.filter(Contact.is_active == True) return q.order_by(Contact.name).all() @contacts_router.post("/", response_model=ContactOut, status_code=status.HTTP_201_CREATED) def create_contact(body: ContactCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): contact = Contact(**body.model_dump()) db.add(contact) db.commit() db.refresh(contact) return contact @contacts_router.put("/{contact_id}", response_model=ContactOut) def update_contact(contact_id: int, body: ContactUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): contact = db.query(Contact).filter(Contact.id == contact_id).first() if not contact: raise HTTPException(status_code=404, detail="Contact not found") for field, value in body.model_dump(exclude_none=True).items(): setattr(contact, field, value) db.commit() db.refresh(contact) return contact @contacts_router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_contact(contact_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): contact = db.query(Contact).filter(Contact.id == contact_id).first() if not contact: raise HTTPException(status_code=404, detail="Contact not found") contact.is_active = False db.commit() # ── Expenses ────────────────────────────────────────────────────────────────── @expenses_router.get("/summary") def expense_summary( db: Session = Depends(get_db), user: User = Depends(require_manager), ): expenses = db.query(Expense).all() total_due = sum(max(0.0, e.total_amount - e.paid_amount) for e in expenses if e.paid_amount < e.total_amount) total_paid = sum(e.paid_amount for e in expenses) by_category: dict = {} for e in expenses: c = e.category if c not in by_category: by_category[c] = {"category": c, "total": 0.0, "paid": 0.0, "due": 0.0} by_category[c]["total"] += e.total_amount by_category[c]["paid"] += e.paid_amount by_category[c]["due"] += max(0.0, e.total_amount - e.paid_amount) return { "total_due": round(total_due, 2), "total_paid": round(total_paid, 2), "by_category": list(by_category.values()), } @expenses_router.get("/", response_model=List[ExpenseOut]) def list_expenses( expense_status: Optional[str] = Query(default=None, alias="status"), category: Optional[str] = None, contact_id: Optional[int] = None, business_day_id: Optional[int] = None, db: Session = Depends(get_db), user: User = Depends(require_manager), ): expenses = db.query(Expense).order_by(Expense.created_at.desc()).all() result = [_expense_out(e) for e in expenses] if expense_status: result = [e for e in result if e["status"] == expense_status] if category: result = [e for e in result if e["category"] == category] if contact_id: result = [e for e in result if e["contact_id"] == contact_id] if business_day_id: result = [e for e in result if e["business_day_id"] == business_day_id] return result @expenses_router.post("/", response_model=ExpenseOut, status_code=status.HTTP_201_CREATED) def create_expense(body: ExpenseCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): expense = Expense(**body.model_dump(), created_by_id=user.id) db.add(expense) db.commit() db.refresh(expense) return _expense_out(expense) @expenses_router.put("/{expense_id}", response_model=ExpenseOut) def update_expense(expense_id: int, body: ExpenseUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): expense = db.query(Expense).filter(Expense.id == expense_id).first() if not expense: raise HTTPException(status_code=404, detail="Expense not found") for field, value in body.model_dump(exclude_none=True).items(): setattr(expense, field, value) db.commit() db.refresh(expense) return _expense_out(expense) @expenses_router.delete("/{expense_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_expense(expense_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): expense = db.query(Expense).filter(Expense.id == expense_id).first() if not expense: raise HTTPException(status_code=404, detail="Expense not found") db.delete(expense) db.commit() @expenses_router.post("/{expense_id}/pay", response_model=ExpenseOut) def record_payment( expense_id: int, body: ExpensePaymentCreate, db: Session = Depends(get_db), user: User = Depends(require_manager), ): expense = db.query(Expense).filter(Expense.id == expense_id).first() if not expense: raise HTTPException(status_code=404, detail="Expense not found") if body.amount <= 0: raise HTTPException(status_code=400, detail="Payment amount must be positive") payment = ExpensePayment( expense_id=expense_id, amount=body.amount, paid_by_id=user.id, notes=body.notes, ) db.add(payment) db.flush() # recompute paid_amount from all payments (never trust client totals) all_payments = db.query(ExpensePayment).filter(ExpensePayment.expense_id == expense_id).all() expense.paid_amount = round(sum(p.amount for p in all_payments), 2) db.commit() db.refresh(expense) return _expense_out(expense)