swiftops-backend / src /app /api /v1 /expenses.py
kamau1's picture
feat: unified sync notification system, realtime timesheets and payroll, simplified project role compensation
95005e1
"""
Expense API Endpoints - Ticket expense management
Provides endpoints for:
- Creating expenses with location verification
- Listing and retrieving expenses
- Approval/rejection workflow
- Payment routing details (who, how, where to send money)
- Marking expenses as paid
- Statistics and reporting
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
from sqlalchemy.orm import Session
from typing import Optional, List
from uuid import UUID
from app.api.deps import get_db, get_current_user
from app.models.user import User
from app.schemas.ticket_expense import (
TicketExpenseCreate,
TicketExpenseUpdate,
TicketExpenseApprove,
TicketExpenseMarkPaid,
TicketExpensePaymentDetails,
TicketExpenseResponse,
TicketExpenseListResponse,
TicketExpenseStats,
)
from app.services.expense_service import ExpenseService
from app.core.exceptions import NotFoundException, ValidationException
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/expenses", tags=["Expenses"])
# ============================================
# CREATE EXPENSE
# ============================================
@router.post(
"",
response_model=TicketExpenseResponse,
status_code=status.HTTP_201_CREATED,
summary="Create expense",
description="""
Create a new ticket expense.
**Workflow:**
1. Upload receipt document first (if applicable)
2. Create expense with assignment ID
3. System automatically verifies location (checks if user was at customer site)
4. Expense is created in pending state
5. Manager approves/rejects expense
6. Finance sets payment details (who, how, where to send money)
7. Finance marks as paid with transaction reference
**Location Verification:**
- System checks if user changed ticket status at customer location
- This prevents fraud (agent must be face-to-face with customer)
- If not verified, expense requires manager approval
**Categories:**
- transport: Travel costs
- materials: Materials purchased
- meals: Meal expenses
- accommodation: Hotel/lodging
- other: Other expenses
"""
)
def create_expense(
data: TicketExpenseCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new expense"""
try:
expense = ExpenseService.create_expense(db, data, current_user.id)
# Add user names for response
response = TicketExpenseResponse.model_validate(expense)
response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
return response
except NotFoundException as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValidationException as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================
# LIST EXPENSES
# ============================================
@router.get(
"",
response_model=TicketExpenseListResponse,
summary="List expenses",
description="""
List expenses with filters.
**Filters:**
- ticket_id: Filter by ticket
- assignment_id: Filter by assignment
- incurred_by_user_id: Filter by user who incurred expense
- category: Filter by category
- is_approved: Filter by approval status
- is_paid: Filter by payment status
**Use Cases:**
- View all expenses for a ticket
- View all expenses for a user
- Find unpaid expenses (is_approved=true, is_paid=false)
- Find pending approvals (is_approved=false)
"""
)
def list_expenses(
ticket_id: Optional[UUID] = Query(None, description="Filter by ticket"),
assignment_id: Optional[UUID] = Query(None, description="Filter by assignment"),
incurred_by_user_id: Optional[UUID] = Query(None, description="Filter by user"),
category: Optional[str] = Query(None, description="Filter by category"),
is_approved: Optional[bool] = Query(None, description="Filter by approval status"),
is_paid: Optional[bool] = Query(None, description="Filter by payment status"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=100, description="Items per page"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List expenses with filters"""
skip = (page - 1) * page_size
expenses, total = ExpenseService.list_expenses(
db,
ticket_id=ticket_id,
assignment_id=assignment_id,
incurred_by_user_id=incurred_by_user_id,
category=category,
is_approved=is_approved,
is_paid=is_paid,
skip=skip,
limit=page_size
)
# Add user names
expense_responses = []
for expense in expenses:
response = TicketExpenseResponse.model_validate(expense)
response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
response.approved_by_user_name = expense.approved_by_user.full_name if expense.approved_by_user else None
response.paid_to_user_name = expense.paid_to_user.full_name if expense.paid_to_user else None
expense_responses.append(response)
pages = (total + page_size - 1) // page_size
return TicketExpenseListResponse(
expenses=expense_responses,
total=total,
page=page,
page_size=page_size,
pages=pages
)
# ============================================
# GET EXPENSE
# ============================================
@router.get(
"/{expense_id}",
response_model=TicketExpenseResponse,
summary="Get expense",
description="Get expense by ID with all details"
)
def get_expense(
expense_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get expense by ID"""
try:
expense = ExpenseService.get_expense(db, expense_id)
response = TicketExpenseResponse.model_validate(expense)
response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
response.approved_by_user_name = expense.approved_by_user.full_name if expense.approved_by_user else None
response.paid_to_user_name = expense.paid_to_user.full_name if expense.paid_to_user else None
return response
except NotFoundException as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# ============================================
# UPDATE EXPENSE
# ============================================
@router.patch(
"/{expense_id}",
response_model=TicketExpenseResponse,
summary="Update expense",
description="""
Update expense details (only before approval).
**Rules:**
- Only creator can update
- Cannot update approved expenses
- Can update: description, category, amount, receipt, notes
"""
)
def update_expense(
expense_id: UUID,
data: TicketExpenseUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update expense"""
try:
expense = ExpenseService.update_expense(db, expense_id, data, current_user.id)
response = TicketExpenseResponse.model_validate(expense)
response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
return response
except NotFoundException as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValidationException as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================
# APPROVE/REJECT EXPENSE
# ============================================
@router.post(
"/{expense_id}/approve",
response_model=TicketExpenseResponse,
summary="Approve or reject expense",
description="""
Approve or reject an expense.
**Workflow:**
1. Manager reviews expense
2. Checks receipt, location verification, amount
3. Approves or rejects with reason
**Rules:**
- Only managers can approve
- Must provide rejection_reason if rejecting
- Cannot change after approval
"""
)
def approve_expense(
expense_id: UUID,
data: TicketExpenseApprove,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Approve or reject expense"""
try:
expense = ExpenseService.approve_expense(db, expense_id, data, current_user.id, background_tasks)
response = TicketExpenseResponse.model_validate(expense)
response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
response.approved_by_user_name = expense.approved_by_user.full_name if expense.approved_by_user else None
return response
except NotFoundException as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValidationException as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================
# UPDATE PAYMENT DETAILS
# ============================================
@router.post(
"/{expense_id}/payment-details",
response_model=TicketExpenseResponse,
summary="Set payment routing details",
description="""
Set payment routing details for approved expense.
**This is critical for finance department** to know:
- WHO receives payment (agent or vendor)
- HOW to send money (M-Pesa, bank, cash)
- WHERE to send money (phone number, till number, account details)
**Payment Methods:**
- send_money: M-Pesa Send Money (phone number)
- till_number: M-Pesa Till Number (business till)
- paybill: M-Pesa Paybill (business number + account)
- pochi_la_biashara: M-Pesa Business Wallet (phone number)
- bank_transfer: Bank account transfer
- cash: Cash payment (requires recipient verification)
**Examples:**
Agent reimbursement via M-Pesa:
```json
{
"payment_recipient_type": "agent",
"payment_method": "send_money",
"payment_details": {
"phone_number": "+254712345678",
"recipient_name": "John Doe"
}
}
```
Vendor payment via Till Number:
```json
{
"payment_recipient_type": "vendor",
"payment_method": "till_number",
"payment_details": {
"till_number": "123456",
"business_name": "ABC Hardware"
}
}
```
**Rules:**
- Must be approved first
- Cannot update after paid
- payment_details must match payment_method type
"""
)
def update_payment_details(
expense_id: UUID,
data: TicketExpensePaymentDetails,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update payment routing details"""
try:
expense = ExpenseService.update_payment_details(db, expense_id, data)
response = TicketExpenseResponse.model_validate(expense)
response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
response.approved_by_user_name = expense.approved_by_user.full_name if expense.approved_by_user else None
return response
except NotFoundException as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValidationException as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================
# MARK AS PAID
# ============================================
@router.post(
"/{expense_id}/mark-paid",
response_model=TicketExpenseResponse,
summary="Mark expense as paid",
description="""
Mark expense as paid with payment reference.
**Workflow:**
1. Finance department processes payment
2. Sends money via specified payment method
3. Marks expense as paid with transaction reference
**Rules:**
- Must be approved first
- Must have payment_details set
- Cannot change after paid
- payment_reference is transaction ID (M-Pesa code, bank reference, etc.)
"""
)
def mark_expense_paid(
expense_id: UUID,
data: TicketExpenseMarkPaid,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Mark expense as paid"""
try:
expense = ExpenseService.mark_paid(db, expense_id, data, background_tasks)
response = TicketExpenseResponse.model_validate(expense)
response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
response.approved_by_user_name = expense.approved_by_user.full_name if expense.approved_by_user else None
response.paid_to_user_name = expense.paid_to_user.full_name if expense.paid_to_user else None
return response
except NotFoundException as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValidationException as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================
# STATISTICS
# ============================================
@router.get(
"/stats",
response_model=TicketExpenseStats,
summary="Get expense statistics",
description="""
Get expense statistics and metrics.
**Returns:**
- Total expenses count and amount
- Approved, pending, rejected counts and amounts
- Paid and unpaid counts and amounts
- Breakdown by category
**Use Cases:**
- Dashboard metrics
- Financial reporting
- Budget tracking
"""
)
def get_expense_stats(
ticket_id: Optional[UUID] = Query(None, description="Filter by ticket"),
assignment_id: Optional[UUID] = Query(None, description="Filter by assignment"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get expense statistics"""
stats = ExpenseService.get_expense_stats(db, ticket_id, assignment_id)
return TicketExpenseStats(**stats)
# ============================================
# DELETE EXPENSE
# ============================================
@router.delete(
"/{expense_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete expense",
description="""
Soft delete expense (only before approval).
**Rules:**
- Only creator can delete
- Cannot delete approved expenses
"""
)
def delete_expense(
expense_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete expense"""
try:
ExpenseService.delete_expense(db, expense_id, current_user.id)
return None
except NotFoundException as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except ValidationException as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))