Spaces:
Sleeping
Sleeping
| """ | |
| Ticket Expenses API - Money-Safe Expense Management | |
| Endpoints for: | |
| 1. Create/update/delete expenses | |
| 2. Approve/reject expenses | |
| 3. Mark expenses as paid | |
| 4. Update payment details | |
| 5. List/filter expenses | |
| 6. Get expense statistics | |
| Authorization: | |
| - Field agents: Can create/view/update their own expenses | |
| - PM/Dispatcher: Can approve, reject, mark as paid, view all | |
| - Platform admin: Full access | |
| """ | |
| from fastapi import APIRouter, Depends, status, Query, HTTPException, BackgroundTasks | |
| from sqlalchemy.orm import Session, joinedload | |
| from typing import Optional, List | |
| from uuid import UUID | |
| from datetime import date, datetime, timedelta | |
| import logging | |
| from app.api.deps import get_db, get_current_user | |
| from app.models.user import User | |
| from app.models.enums import AppRole | |
| from app.services.ticket_expense_service import TicketExpenseService | |
| from app.models.ticket_expense import TicketExpense | |
| from app.models.ticket import Ticket | |
| from app.schemas.ticket_expense import ( | |
| TicketExpenseCreate, | |
| TicketExpenseUpdate, | |
| TicketExpenseApprove, | |
| TicketExpenseMarkPaid, | |
| TicketExpensePaymentDetails, | |
| TicketExpenseResponse, | |
| TicketExpenseListResponse, | |
| TicketExpenseStats, | |
| ) | |
| from app.schemas.filters import ExpenseFilters | |
| router = APIRouter() | |
| logger = logging.getLogger(__name__) | |
| # ============================================ | |
| # FILTER PARSING | |
| # ============================================ | |
| def parse_expense_filters( | |
| ticket_id: Optional[UUID] = Query(None), | |
| assignment_id: Optional[UUID] = Query(None), | |
| user_id: Optional[UUID] = Query(None), | |
| project_id: Optional[UUID] = Query(None), | |
| category: Optional[str] = Query(None), | |
| payment_method: Optional[str] = Query(None), | |
| payment_recipient_type: Optional[str] = Query(None), | |
| expense_date: Optional[date] = Query(None), | |
| expense_date_from: Optional[date] = Query(None), | |
| expense_date_to: Optional[date] = Query(None), | |
| is_approved: Optional[bool] = Query(None), | |
| is_paid: Optional[bool] = Query(None), | |
| location_verified: Optional[bool] = Query(None), | |
| has_receipt: Optional[bool] = Query(None), | |
| has_payment_details: Optional[bool] = Query(None), | |
| ready_for_payment: Optional[bool] = Query(None), | |
| date_range: Optional[str] = Query(None), | |
| search: Optional[str] = Query(None), | |
| sort_by: Optional[str] = Query(None), | |
| sort_order: str = Query("desc"), | |
| page: int = Query(1, ge=1), | |
| page_size: int = Query(50, ge=1, le=100), | |
| from_date: Optional[date] = Query(None), | |
| to_date: Optional[date] = Query(None), | |
| ) -> ExpenseFilters: | |
| """Parse and convert query parameters to ExpenseFilters""" | |
| # Parse comma-separated multi-value filters | |
| def parse_csv(value: Optional[str]) -> Optional[List[str]]: | |
| if value is None: | |
| return None | |
| return [item.strip() for item in value.split(',') if item.strip()] | |
| # Handle quick date range filters | |
| if date_range: | |
| today = date.today() | |
| if date_range == "today": | |
| expense_date_from = today | |
| expense_date_to = today | |
| elif date_range == "yesterday": | |
| yesterday = today - timedelta(days=1) | |
| expense_date_from = yesterday | |
| expense_date_to = yesterday | |
| elif date_range == "this_week": | |
| # Monday to Sunday | |
| start_of_week = today - timedelta(days=today.weekday()) | |
| expense_date_from = start_of_week | |
| expense_date_to = today | |
| elif date_range == "last_week": | |
| start_of_last_week = today - timedelta(days=today.weekday() + 7) | |
| end_of_last_week = start_of_last_week + timedelta(days=6) | |
| expense_date_from = start_of_last_week | |
| expense_date_to = end_of_last_week | |
| elif date_range == "this_month": | |
| expense_date_from = today.replace(day=1) | |
| expense_date_to = today | |
| elif date_range == "last_month": | |
| first_of_this_month = today.replace(day=1) | |
| last_day_of_last_month = first_of_this_month - timedelta(days=1) | |
| expense_date_from = last_day_of_last_month.replace(day=1) | |
| expense_date_to = last_day_of_last_month | |
| # Support legacy from_date/to_date parameters | |
| if from_date and not expense_date_from: | |
| expense_date_from = from_date | |
| if to_date and not expense_date_to: | |
| expense_date_to = to_date | |
| return ExpenseFilters( | |
| ticket_id=ticket_id, | |
| assignment_id=assignment_id, | |
| user_id=user_id, | |
| project_id=project_id, | |
| category=parse_csv(category), | |
| payment_method=parse_csv(payment_method), | |
| payment_recipient_type=parse_csv(payment_recipient_type), | |
| expense_date=expense_date, | |
| expense_date_from=expense_date_from, | |
| expense_date_to=expense_date_to, | |
| is_approved=is_approved, | |
| is_paid=is_paid, | |
| location_verified=location_verified, | |
| has_receipt=has_receipt, | |
| has_payment_details=has_payment_details, | |
| ready_for_payment=ready_for_payment, | |
| date_range=date_range, | |
| search=search, | |
| sort_by=sort_by, | |
| sort_order=sort_order, | |
| page=page, | |
| page_size=page_size, | |
| from_date=expense_date_from, | |
| to_date=expense_date_to, | |
| ) | |
| # ============================================ | |
| # CREATE EXPENSE | |
| # ============================================ | |
| def create_expense( | |
| data: TicketExpenseCreate, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Create a new ticket expense. | |
| **Authorization:** Field agent (own assignments), PM, Dispatcher, Platform Admin | |
| **Business Rules:** | |
| - Assignment must be active (not ended) | |
| - Ticket must not be completed/cancelled | |
| - Expense date cannot be in the future | |
| - Location is automatically verified from GPS history | |
| **Example:** | |
| ```json | |
| { | |
| "ticket_assignment_id": "uuid-here", | |
| "category": "transport", | |
| "description": "Taxi to customer site", | |
| "expense_date": "2024-11-30", | |
| "quantity": 1, | |
| "unit": "trip", | |
| "unit_cost": 500.00, | |
| "total_cost": 500.00, | |
| "notes": "Shared with team member" | |
| } | |
| ``` | |
| """ | |
| expense = TicketExpenseService.create_expense( | |
| db=db, | |
| data=data, | |
| current_user=current_user | |
| ) | |
| # Build response with user names | |
| response = TicketExpenseResponse.model_validate(expense) | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| return response | |
| # ============================================ | |
| # UPDATE EXPENSE | |
| # ============================================ | |
| def update_expense( | |
| expense_id: UUID, | |
| data: TicketExpenseUpdate, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Update an expense (before approval only). | |
| **Authorization:** Expense creator, PM, Dispatcher, Platform Admin | |
| **Restrictions:** | |
| - Cannot update approved expenses | |
| - Cannot update paid expenses | |
| - Changing expense_date re-triggers location verification | |
| **Example:** | |
| ```json | |
| { | |
| "description": "Updated description", | |
| "total_cost": 600.00, | |
| "notes": "Price increased" | |
| } | |
| ``` | |
| """ | |
| expense = TicketExpenseService.update_expense( | |
| db=db, | |
| expense_id=expense_id, | |
| data=data, | |
| current_user=current_user | |
| ) | |
| response = TicketExpenseResponse.model_validate(expense) | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| if expense.approved_by_user: | |
| response.approved_by_user_name = expense.approved_by_user.name | |
| if expense.paid_to_user: | |
| response.paid_to_user_name = expense.paid_to_user.name | |
| return response | |
| # ============================================ | |
| # APPROVE/REJECT EXPENSE | |
| # ============================================ | |
| def approve_expense( | |
| expense_id: UUID, | |
| data: TicketExpenseApprove, | |
| background_tasks: BackgroundTasks, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Approve or reject an expense. | |
| **Authorization:** PM, Dispatcher, Platform Admin only | |
| **Business Rules:** | |
| - Cannot re-approve already approved expense | |
| - Rejection requires reason | |
| - Approved expenses can be paid | |
| **Example (Approve):** | |
| ```json | |
| { | |
| "is_approved": true | |
| } | |
| ``` | |
| **Example (Reject):** | |
| ```json | |
| { | |
| "is_approved": false, | |
| "rejection_reason": "Receipt not clear, please resubmit" | |
| } | |
| ``` | |
| """ | |
| expense = TicketExpenseService.approve_expense( | |
| db=db, | |
| expense_id=expense_id, | |
| data=data, | |
| current_user=current_user, | |
| background_tasks=background_tasks | |
| ) | |
| response = TicketExpenseResponse.model_validate(expense) | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| if expense.approved_by_user: | |
| response.approved_by_user_name = expense.approved_by_user.name | |
| return response | |
| # ============================================ | |
| # MARK AS PAID | |
| # ============================================ | |
| def mark_expense_paid( | |
| expense_id: UUID, | |
| data: TicketExpenseMarkPaid, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Mark expense as paid. | |
| **Authorization:** PM, Dispatcher, Platform Admin only | |
| **Business Rules:** | |
| - Must be approved first | |
| - Cannot mark as paid twice | |
| - Payment reference is optional but recommended | |
| **Example:** | |
| ```json | |
| { | |
| "paid_to_user_id": "uuid-here", | |
| "payment_reference": "MPESA-ABC123456" | |
| } | |
| ``` | |
| """ | |
| expense = TicketExpenseService.mark_as_paid( | |
| db=db, | |
| expense_id=expense_id, | |
| data=data, | |
| current_user=current_user | |
| ) | |
| response = TicketExpenseResponse.model_validate(expense) | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| if expense.approved_by_user: | |
| response.approved_by_user_name = expense.approved_by_user.name | |
| if expense.paid_to_user: | |
| response.paid_to_user_name = expense.paid_to_user.name | |
| return response | |
| # ============================================ | |
| # UPDATE PAYMENT DETAILS | |
| # ============================================ | |
| def update_payment_details( | |
| expense_id: UUID, | |
| data: TicketExpensePaymentDetails, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Update payment routing details (who gets paid and how). | |
| **Authorization:** Expense creator, PM, Dispatcher, Platform Admin | |
| **Business Rules:** | |
| - Can update before or after approval | |
| - Cannot update after payment | |
| - Payment details must match payment method | |
| **Example (Send Money):** | |
| ```json | |
| { | |
| "payment_recipient_type": "agent", | |
| "payment_method": "send_money", | |
| "payment_details": { | |
| "phone_number": "+254712345678", | |
| "recipient_name": "John Doe" | |
| } | |
| } | |
| ``` | |
| **Example (Till Number):** | |
| ```json | |
| { | |
| "payment_recipient_type": "vendor", | |
| "payment_method": "till_number", | |
| "payment_details": { | |
| "till_number": "123456", | |
| "business_name": "ABC Hardware" | |
| } | |
| } | |
| ``` | |
| """ | |
| expense = TicketExpenseService.update_payment_details( | |
| db=db, | |
| expense_id=expense_id, | |
| data=data, | |
| current_user=current_user | |
| ) | |
| response = TicketExpenseResponse.model_validate(expense) | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| if expense.approved_by_user: | |
| response.approved_by_user_name = expense.approved_by_user.name | |
| if expense.paid_to_user: | |
| response.paid_to_user_name = expense.paid_to_user.name | |
| return response | |
| # ============================================ | |
| # DELETE EXPENSE | |
| # ============================================ | |
| def delete_expense( | |
| expense_id: UUID, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Soft delete an expense. | |
| **Authorization:** Expense creator, PM, Dispatcher, Platform Admin | |
| **Restrictions:** | |
| - Cannot delete approved expenses | |
| - Cannot delete paid expenses | |
| - Use rejection instead for approved expenses | |
| """ | |
| TicketExpenseService.delete_expense( | |
| db=db, | |
| expense_id=expense_id, | |
| current_user=current_user | |
| ) | |
| return None | |
| # ============================================ | |
| # FIELD AGENT HELPER ENDPOINTS | |
| # ============================================ | |
| def get_my_expenses( | |
| filters: ExpenseFilters = Depends(parse_expense_filters), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get all expenses for the current user with comprehensive filtering. | |
| **Purpose:** Field agents can see all their expenses in one place | |
| **Supports all expense filters:** | |
| - `is_approved`: Filter by approval status | |
| - `is_paid`: Filter by payment status | |
| - `category`: Filter by category (transport,materials,meals) | |
| - `date_range`: Quick filter (today,yesterday,this_week,last_week,this_month,last_month) | |
| - `expense_date_from/to`: Date range | |
| - `has_receipt`: Filter expenses with receipts | |
| - `search`: Search description and notes | |
| - And more... | |
| **Examples:** | |
| ``` | |
| # Pending approval | |
| GET /ticket-expenses/my-expenses?is_approved=false | |
| # This week's expenses | |
| GET /ticket-expenses/my-expenses?date_range=this_week | |
| # Transport expenses only | |
| GET /ticket-expenses/my-expenses?category=transport | |
| ``` | |
| **Returns:** List of user's expenses with full context (ticket info, approval/payment status) | |
| """ | |
| # Force filter to current user | |
| filters.user_id = current_user.id | |
| expenses, total = TicketExpenseService.list_expenses_with_filters( | |
| db=db, | |
| current_user=current_user, | |
| filters=filters | |
| ) | |
| # Build responses with contextual information | |
| expense_responses = [] | |
| for expense in expenses: | |
| response = TicketExpenseResponse.model_validate(expense) | |
| # User names | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| if expense.approved_by_user: | |
| response.approved_by_user_name = expense.approved_by_user.name | |
| if expense.paid_to_user: | |
| response.paid_to_user_name = expense.paid_to_user.name | |
| # Ticket context | |
| if expense.ticket: | |
| response.ticket_reference = str(expense.ticket.id) | |
| response.ticket_title = expense.ticket.ticket_name | |
| response.ticket_status = expense.ticket.status | |
| # Assignment context | |
| if expense.assignment: | |
| response.assignment_status = expense.assignment.status | |
| expense_responses.append(response) | |
| pages = (total + filters.page_size - 1) // filters.page_size | |
| return TicketExpenseListResponse( | |
| expenses=expense_responses, | |
| total=total, | |
| page=filters.page, | |
| page_size=filters.page_size, | |
| pages=pages | |
| ) | |
| def get_my_expense_stats( | |
| from_date: Optional[date] = Query(None, description="Filter from date"), | |
| to_date: Optional[date] = Query(None, description="Filter to date"), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get expense statistics for the current user. | |
| **Purpose:** Field agents can see their own expense summary | |
| **Returns:** | |
| - Total expenses and amount | |
| - Pending approval count and amount | |
| - Approved/paid breakdown | |
| - Category breakdown | |
| """ | |
| stats = TicketExpenseService.get_expense_stats( | |
| db=db, | |
| current_user=current_user, | |
| user_id=current_user.id, # Force filter to current user | |
| from_date=from_date, | |
| to_date=to_date | |
| ) | |
| return stats | |
| # ============================================ | |
| # GET STATISTICS | |
| # ============================================ | |
| def get_expense_stats_simple( | |
| project_id: Optional[UUID] = Query(None, description="Filter by project"), | |
| ticket_id: Optional[UUID] = Query(None, description="Filter by ticket"), | |
| user_id: Optional[UUID] = Query(None, description="Filter by user"), | |
| from_date: Optional[date] = Query(None, description="Filter from date"), | |
| to_date: Optional[date] = Query(None, description="Filter to date"), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get expense statistics (matches sales-orders/stats pattern). | |
| **Authorization:** | |
| - Field agents: See only their own stats | |
| - PM/Dispatcher/Admin: See all stats in their scope | |
| **Filters:** | |
| - project_id: Filter by project | |
| - ticket_id: Filter by ticket | |
| - user_id: Filter by user | |
| - from_date/to_date: Date range | |
| **Returns:** | |
| - Total expenses count and amount | |
| - Approved/pending/rejected breakdown | |
| - Paid/unpaid breakdown | |
| - Breakdown by category | |
| """ | |
| stats = TicketExpenseService.get_expense_stats( | |
| db=db, | |
| current_user=current_user, | |
| project_id=project_id, | |
| ticket_id=ticket_id, | |
| user_id=user_id, | |
| from_date=from_date, | |
| to_date=to_date | |
| ) | |
| return stats | |
| def get_expense_stats_detailed( | |
| project_id: Optional[UUID] = Query(None, description="Filter by project"), | |
| ticket_id: Optional[UUID] = Query(None, description="Filter by ticket"), | |
| user_id: Optional[UUID] = Query(None, description="Filter by user"), | |
| from_date: Optional[date] = Query(None, description="Filter from date"), | |
| to_date: Optional[date] = Query(None, description="Filter to date"), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get expense statistics and summaries. | |
| **Authorization:** | |
| - Field agents: See only their own stats | |
| - PM/Dispatcher/Admin: See all stats in their scope | |
| **Returns:** | |
| - Total expenses count and amount | |
| - Approved/pending/rejected breakdown | |
| - Paid/unpaid breakdown | |
| - Breakdown by category | |
| """ | |
| stats = TicketExpenseService.get_expense_stats( | |
| db=db, | |
| current_user=current_user, | |
| project_id=project_id, | |
| ticket_id=ticket_id, | |
| user_id=user_id, | |
| from_date=from_date, | |
| to_date=to_date | |
| ) | |
| return stats | |
| # ============================================ | |
| # GET EXPENSE | |
| # ============================================ | |
| def get_expense( | |
| expense_id: UUID, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get expense details by ID. | |
| **Authorization:** Expense creator, PM, Dispatcher, Platform Admin | |
| """ | |
| expense = TicketExpenseService.get_expense( | |
| db=db, | |
| expense_id=expense_id, | |
| current_user=current_user | |
| ) | |
| response = TicketExpenseResponse.model_validate(expense) | |
| # User names | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| if expense.approved_by_user: | |
| response.approved_by_user_name = expense.approved_by_user.name | |
| if expense.paid_to_user: | |
| response.paid_to_user_name = expense.paid_to_user.name | |
| # Ticket context | |
| if expense.ticket: | |
| response.ticket_reference = str(expense.ticket.id) | |
| response.ticket_title = expense.ticket.ticket_name | |
| response.ticket_status = expense.ticket.status | |
| # Assignment context | |
| if expense.assignment: | |
| response.assignment_status = expense.assignment.status | |
| return response | |
| # ============================================ | |
| # LIST EXPENSES | |
| # ============================================ | |
| def list_expenses( | |
| filters: ExpenseFilters = Depends(parse_expense_filters), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| List expenses with comprehensive filtering and pagination. | |
| **Authorization:** | |
| - Field agents: See only their own expenses | |
| - PM/Dispatcher/Admin: See all expenses in their scope | |
| **Filters:** | |
| - `ticket_id`: Filter by specific ticket | |
| - `assignment_id`: Filter by specific assignment | |
| - `user_id`: Filter by technician who incurred expense | |
| - `project_id`: Filter by project (via ticket) | |
| - `category`: Filter by category (comma-separated: transport,materials,meals) | |
| - `payment_method`: Filter by payment method (comma-separated) | |
| - `payment_recipient_type`: Filter by recipient (agent,vendor) | |
| - `is_approved`: Filter by approval status | |
| - `is_paid`: Filter by payment status | |
| - `location_verified`: Filter by location verification | |
| - `has_receipt`: Filter expenses with receipts | |
| - `has_payment_details`: Filter expenses with payment details | |
| - `ready_for_payment`: Filter expenses ready for payment (approved + unpaid + has payment details) | |
| - `expense_date`: Filter by exact expense date | |
| - `expense_date_from`: Filter from date (inclusive) | |
| - `expense_date_to`: Filter to date (inclusive) | |
| - `date_range`: Quick filter (today,yesterday,this_week,last_week,this_month,last_month) | |
| - `search`: Search across description and notes | |
| - `sort_by`: Field to sort by | |
| - `sort_order`: asc or desc | |
| **Examples:** | |
| ``` | |
| # Pending approval, transport only | |
| GET /ticket-expenses?is_approved=false&category=transport | |
| # Ready for payment | |
| GET /ticket-expenses?ready_for_payment=true | |
| # This week's expenses | |
| GET /ticket-expenses?date_range=this_week | |
| # Multiple categories | |
| GET /ticket-expenses?category=transport,materials&date_range=today | |
| ``` | |
| """ | |
| expenses, total = TicketExpenseService.list_expenses_with_filters( | |
| db=db, | |
| current_user=current_user, | |
| filters=filters | |
| ) | |
| # Build responses with user names and contextual information | |
| expense_responses = [] | |
| for expense in expenses: | |
| response = TicketExpenseResponse.model_validate(expense) | |
| # User names | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| if expense.approved_by_user: | |
| response.approved_by_user_name = expense.approved_by_user.name | |
| if expense.paid_to_user: | |
| response.paid_to_user_name = expense.paid_to_user.name | |
| # Ticket context | |
| if expense.ticket: | |
| response.ticket_reference = str(expense.ticket.id) | |
| response.ticket_title = expense.ticket.ticket_name | |
| response.ticket_status = expense.ticket.status | |
| # Assignment context | |
| if expense.assignment: | |
| response.assignment_status = expense.assignment.status | |
| expense_responses.append(response) | |
| pages = (total + filters.page_size - 1) // filters.page_size | |
| return TicketExpenseListResponse( | |
| expenses=expense_responses, | |
| total=total, | |
| page=filters.page, | |
| page_size=filters.page_size, | |
| pages=pages | |
| ) | |
| # ============================================ | |
| # BULK OPERATIONS | |
| # ============================================ | |
| def bulk_approve_expenses( | |
| background_tasks: BackgroundTasks, | |
| expense_ids: List[UUID] = Query(..., description="List of expense IDs to approve/reject"), | |
| is_approved: bool = Query(..., description="True to approve, False to reject"), | |
| rejection_reason: Optional[str] = Query(None, description="Reason for rejection (required if rejecting)"), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Bulk approve or reject multiple expenses at once. | |
| **Authorization:** PM, Dispatcher, Platform Admin only | |
| **Use Case:** Select multiple expenses in UI and approve/reject all at once | |
| **Business Rules:** | |
| - Skips already approved expenses (returns in errors list) | |
| - All updates in single transaction (all or nothing) | |
| - Rejection requires reason | |
| **Request:** | |
| ``` | |
| POST /ticket-expenses/bulk-approve?expense_ids=uuid1&expense_ids=uuid2&is_approved=true | |
| ``` | |
| **Response:** | |
| ```json | |
| { | |
| "updated_count": 5, | |
| "updated_expenses": [...], | |
| "errors": ["Expense uuid3 already approved"] | |
| } | |
| ``` | |
| **Example - Approve Multiple:** | |
| ``` | |
| POST /ticket-expenses/bulk-approve?expense_ids=uuid1&expense_ids=uuid2&is_approved=true | |
| ``` | |
| **Example - Reject Multiple:** | |
| ``` | |
| POST /ticket-expenses/bulk-approve?expense_ids=uuid1&expense_ids=uuid2&is_approved=false&rejection_reason=Missing%20receipts | |
| ``` | |
| """ | |
| updated_expenses, errors = TicketExpenseService.bulk_approve_expenses( | |
| db=db, | |
| expense_ids=expense_ids, | |
| is_approved=is_approved, | |
| rejection_reason=rejection_reason, | |
| current_user=current_user, | |
| background_tasks=background_tasks | |
| ) | |
| # Build responses | |
| expense_responses = [] | |
| for expense in updated_expenses: | |
| response = TicketExpenseResponse.model_validate(expense) | |
| if expense.incurred_by_user: | |
| response.incurred_by_user_name = expense.incurred_by_user.name | |
| if expense.approved_by_user: | |
| response.approved_by_user_name = expense.approved_by_user.name | |
| expense_responses.append(response) | |
| return { | |
| "updated_count": len(updated_expenses), | |
| "updated_expenses": expense_responses, | |
| "errors": errors | |
| } | |
| # ============================================ | |
| # EXPORT FOR PAYMENT | |
| # ============================================ | |
| def bulk_export_expenses( | |
| background_tasks: BackgroundTasks, | |
| expense_ids: List[UUID] = Query(..., description="List of expense IDs to export"), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Export specific expenses by ID for payment processing. | |
| **Authorization:** PM, Dispatcher, Platform Admin only | |
| **Use Case:** Select specific expenses in UI and export only those | |
| **Request:** | |
| ``` | |
| POST /ticket-expenses/bulk-export?expense_ids=uuid1&expense_ids=uuid2 | |
| ``` | |
| **Returns:** CSV file download | |
| **⚠️ Side Effect:** Marks all exported expenses as paid (irreversible!) | |
| """ | |
| from fastapi.responses import StreamingResponse | |
| import io | |
| import csv | |
| from app.models.user_financial_account import UserFinancialAccount | |
| from collections import defaultdict | |
| # Authorization | |
| if current_user.role not in [ | |
| AppRole.PLATFORM_ADMIN, | |
| AppRole.PROJECT_MANAGER, | |
| AppRole.DISPATCHER | |
| ]: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Not authorized to export expenses" | |
| ) | |
| # Validate input | |
| if not expense_ids: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="No expense IDs provided" | |
| ) | |
| logger.info(f"Bulk export requested by user {current_user.id} for {len(expense_ids)} expenses") | |
| # Fetch expenses | |
| try: | |
| expenses = db.query(TicketExpense).options( | |
| joinedload(TicketExpense.incurred_by_user), | |
| joinedload(TicketExpense.ticket), | |
| joinedload(TicketExpense.assignment) | |
| ).filter( | |
| TicketExpense.id.in_(expense_ids), | |
| TicketExpense.is_approved == True, | |
| TicketExpense.is_paid == False, | |
| TicketExpense.deleted_at.is_(None) | |
| ).all() | |
| except Exception as e: | |
| logger.error(f"Failed to fetch expenses for bulk export: {str(e)}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Failed to fetch expenses" | |
| ) | |
| if not expenses: | |
| # Check if expenses exist but are not eligible | |
| all_expenses = db.query(TicketExpense).filter( | |
| TicketExpense.id.in_(expense_ids), | |
| TicketExpense.deleted_at.is_(None) | |
| ).all() | |
| if not all_expenses: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail="No expenses found with provided IDs" | |
| ) | |
| # Expenses exist but not eligible | |
| not_approved = sum(1 for e in all_expenses if not e.is_approved) | |
| already_paid = sum(1 for e in all_expenses if e.is_paid) | |
| detail_parts = [] | |
| if not_approved: | |
| detail_parts.append(f"{not_approved} not approved") | |
| if already_paid: | |
| detail_parts.append(f"{already_paid} already paid") | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail=f"No eligible expenses to export. {', '.join(detail_parts)}" | |
| ) | |
| # Group by user + date | |
| grouped = defaultdict(list) | |
| for expense in expenses: | |
| if expense.payment_recipient_type == "vendor": | |
| key = f"vendor_{expense.id}" | |
| else: | |
| key = (expense.incurred_by_user_id, expense.expense_date) | |
| grouped[key].append(expense) | |
| # Build Tende Pay CSV rows | |
| from app.services.tende_pay_formatter import TendePayFormatter | |
| csv_rows = [] | |
| warnings = [] | |
| exported_expense_ids = set() # Track which expenses were successfully exported | |
| payment_reference = f"CSV_EXPORT_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{current_user.id}" | |
| for key, group_expenses in grouped.items(): | |
| first_expense = group_expenses[0] | |
| # Handle vendor payments | |
| if first_expense.payment_recipient_type == "vendor": | |
| try: | |
| # Validate payment details | |
| is_valid, error_msg = TendePayFormatter.validate_payment_details( | |
| payment_method=first_expense.payment_method, | |
| payment_details=first_expense.payment_details, | |
| user_name=f"Vendor (Expense {first_expense.id})" | |
| ) | |
| if not is_valid: | |
| warnings.append(error_msg) | |
| continue | |
| # Format vendor row | |
| row = TendePayFormatter.format_vendor_payment_row( | |
| expense=first_expense, | |
| payment_method=first_expense.payment_method, | |
| payment_details=first_expense.payment_details | |
| ) | |
| csv_rows.append(row) | |
| exported_expense_ids.add(first_expense.id) # Track successful export | |
| except Exception as e: | |
| logger.error(f"Failed to format vendor payment row: {str(e)}") | |
| warnings.append(f"Skipped vendor expense {first_expense.id}: {str(e)}") | |
| continue | |
| # Handle agent payments | |
| user = first_expense.incurred_by_user | |
| expense_date = first_expense.expense_date | |
| # Safety check: Skip if user is deleted/missing | |
| if not user: | |
| warnings.append( | |
| f"Skipped {len(group_expenses)} expense(s): User deleted or missing (expense IDs: " | |
| f"{', '.join(str(e.id) for e in group_expenses[:3])}{'...' if len(group_expenses) > 3 else ''})" | |
| ) | |
| continue | |
| # Safety check: Skip zero or negative amounts | |
| total_amount = sum(e.total_cost for e in group_expenses) | |
| if total_amount <= 0: | |
| warnings.append( | |
| f"Skipped {len(group_expenses)} expense(s) for {user.name}: " | |
| f"Total amount is {float(total_amount)} (must be positive)" | |
| ) | |
| continue | |
| # Get payment method and details from first expense | |
| payment_method = first_expense.payment_method | |
| payment_details = first_expense.payment_details | |
| # Safety check: Warn if expenses in group have different payment methods | |
| payment_methods_in_group = set(e.payment_method for e in group_expenses if e.payment_method) | |
| if len(payment_methods_in_group) > 1: | |
| warnings.append( | |
| f"WARNING: {len(group_expenses)} expense(s) for {user.name} on {expense_date} have " | |
| f"different payment methods: {', '.join(payment_methods_in_group)}. " | |
| f"Using first expense's method: {payment_method}" | |
| ) | |
| # Try to validate expense payment details first | |
| expense_payment_valid = False | |
| if payment_method and payment_details: | |
| is_valid, error_msg = TendePayFormatter.validate_payment_details( | |
| payment_method=payment_method, | |
| payment_details=payment_details, | |
| user_name=user.name | |
| ) | |
| expense_payment_valid = is_valid | |
| if not is_valid: | |
| logger.warning( | |
| f"Expense payment details invalid for {user.name}: {error_msg}. " | |
| f"Will try financial account as fallback." | |
| ) | |
| # If no payment details on expense OR expense payment details are invalid, | |
| # try to get from financial account | |
| if not expense_payment_valid: | |
| financial_account = db.query(UserFinancialAccount).filter( | |
| UserFinancialAccount.user_id == user.id, | |
| UserFinancialAccount.is_primary == True, | |
| UserFinancialAccount.is_active == True, | |
| UserFinancialAccount.deleted_at.is_(None) | |
| ).first() | |
| if financial_account: | |
| if financial_account.payout_method == "mobile_money": | |
| payment_method = "send_money" | |
| payment_details = { | |
| "phone_number": financial_account.mobile_money_phone, | |
| "recipient_name": financial_account.mobile_money_account_name or user.name | |
| } | |
| logger.info( | |
| f"Using financial account payment details for {user.name} " | |
| f"(expense payment details were invalid or missing)" | |
| ) | |
| elif financial_account.payout_method == "bank_transfer": | |
| payment_method = "bank_transfer" | |
| payment_details = { | |
| "bank_name": financial_account.bank_name, | |
| "account_number": financial_account.bank_account_number, | |
| "account_name": financial_account.bank_account_name | |
| } | |
| logger.info( | |
| f"Using financial account payment details for {user.name} " | |
| f"(expense payment details were invalid or missing)" | |
| ) | |
| # Final validation of payment details (whether from expense or financial account) | |
| if not payment_method or not payment_details: | |
| warnings.append( | |
| f"Skipped {len(group_expenses)} expense(s) for {user.name}: " | |
| f"No valid payment details found (neither on expense nor in financial account)" | |
| ) | |
| continue | |
| is_valid, error_msg = TendePayFormatter.validate_payment_details( | |
| payment_method=payment_method, | |
| payment_details=payment_details, | |
| user_name=user.name | |
| ) | |
| if not is_valid: | |
| warnings.append(f"Skipped {len(group_expenses)} expense(s) for {user.name}: {error_msg}") | |
| continue | |
| # Format agent payment row | |
| try: | |
| row = TendePayFormatter.format_payment_row( | |
| expenses=group_expenses, | |
| user=user, | |
| expense_date=expense_date, | |
| payment_method=payment_method, | |
| payment_details=payment_details, | |
| user_id_number=user.id_number | |
| ) | |
| csv_rows.append(row) | |
| # Track all expenses in this group as successfully exported | |
| for exp in group_expenses: | |
| exported_expense_ids.add(exp.id) | |
| # Warn if user has no ID number | |
| if not user.id_number: | |
| warnings.append(f"User {user.name} has no ID number - exported with empty ID field") | |
| except Exception as e: | |
| logger.error(f"Failed to format payment row for {user.name}: {str(e)}") | |
| warnings.append(f"Skipped {len(group_expenses)} expense(s) for {user.name}: {str(e)}") | |
| # Only mark as paid if we actually generated CSV rows | |
| if not csv_rows: | |
| # Build user-friendly error message | |
| error_summary = "Cannot export payments - please fix the following issues:" | |
| # Categorize warnings for better UX | |
| phone_issues = [w for w in warnings if "phone" in w.lower() or "invalid" in w.lower()] | |
| payment_issues = [w for w in warnings if "payment details" in w.lower() and w not in phone_issues] | |
| other_issues = [w for w in warnings if w not in phone_issues and w not in payment_issues] | |
| error_details = [] | |
| if phone_issues: | |
| error_details.append(f"📱 Phone Number Issues: {'; '.join(phone_issues)}") | |
| if payment_issues: | |
| error_details.append(f"💳 Payment Details Issues: {'; '.join(payment_issues)}") | |
| if other_issues: | |
| error_details.append(f"⚠️ Other Issues: {'; '.join(other_issues)}") | |
| # Add helpful instructions | |
| error_details.append( | |
| "\n💡 How to fix:\n" | |
| "1. Update your financial account with correct payment details\n" | |
| "2. Make sure phone numbers are exactly 12 digits (e.g., 254712345678)\n" | |
| "3. Try exporting again - the system will use your updated financial account" | |
| ) | |
| full_error = f"{error_summary}\n\n" + "\n\n".join(error_details) | |
| logger.warning(f"Bulk export failed for user {current_user.id}: {error_summary}. Total warnings: {len(warnings)}") | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail=full_error | |
| ) | |
| # Mark only successfully exported expenses as paid | |
| try: | |
| paid_count = 0 | |
| for expense in expenses: | |
| # Only mark as paid if this expense was actually exported | |
| if expense.id in exported_expense_ids: | |
| expense.is_paid = True | |
| expense.paid_at = datetime.utcnow() | |
| expense.paid_to_user_id = expense.incurred_by_user_id | |
| expense.payment_reference = payment_reference | |
| paid_count += 1 | |
| db.commit() | |
| # Warn if some expenses were skipped | |
| skipped_count = len(expenses) - paid_count | |
| if skipped_count > 0: | |
| warnings.append( | |
| f"IMPORTANT: {skipped_count} of {len(expenses)} expense(s) were NOT marked as paid due to validation errors. " | |
| f"Only {paid_count} expenses were successfully exported and marked as paid. " | |
| f"Review warnings above and fix issues before re-exporting." | |
| ) | |
| logger.info( | |
| f"Bulk exported {paid_count} of {len(expenses)} expenses by user {current_user.id}. " | |
| f"Generated {len(csv_rows)} payment rows. Skipped: {skipped_count}. Reference: {payment_reference}" | |
| ) | |
| except Exception as e: | |
| db.rollback() | |
| logger.error(f"Failed to mark expenses as paid: {str(e)}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail="Failed to mark expenses as paid" | |
| ) | |
| # Generate Tende Pay CSV | |
| output = io.StringIO() | |
| if csv_rows: | |
| # Tende Pay column order (must match template exactly) | |
| fieldnames = [ | |
| "NAME", | |
| "ID NUMBER", | |
| "PHONE NUMBER", | |
| "AMOUNT", | |
| "PAYMENT MODE", | |
| "BANK (Optional)", | |
| "BANK ACCOUNT NO (Optional)", | |
| "PAYBILL BUSINESS NO (Optional)", | |
| "PAYBILL ACCOUNT NO (Optional)", | |
| "BUY GOODS TILL NO (Optional)", | |
| "BILL PAYMENT BILLER CODE (Optional)", | |
| "BILL PAYMENT ACCOUNT NO (Optional)", | |
| "NARRATION (OPTIONAL)" | |
| ] | |
| writer = csv.DictWriter(output, fieldnames=fieldnames) | |
| writer.writeheader() | |
| writer.writerows(csv_rows) | |
| if warnings: | |
| output.write("\n# WARNINGS:\n") | |
| for warning in warnings: | |
| output.write(f"# {warning}\n") | |
| output.seek(0) | |
| filename = f"tende_pay_bulk_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv" | |
| # Send notification to initiator about export completion | |
| if csv_rows: | |
| try: | |
| from app.services.notification_helper import NotificationHelper | |
| # Get project_id from first expense | |
| first_expense = expenses[0] | |
| ticket = db.query(Ticket).filter(Ticket.id == first_expense.ticket_id).first() | |
| project_id = ticket.project_id if ticket else None | |
| # Calculate total from Tende Pay CSV format (key is "AMOUNT" not "total_amount") | |
| total_amount = sum(row["AMOUNT"] for row in csv_rows) | |
| # Calculate total from actually exported expenses (more accurate) | |
| exported_expenses = [e for e in expenses if e.id in exported_expense_ids] | |
| total_amount_from_expenses = sum(e.total_cost for e in exported_expenses) | |
| background_tasks.add_task( | |
| NotificationHelper.notify_expense_export_complete, | |
| db=db, | |
| user_id=current_user.id, | |
| total_expenses=len(exported_expenses), # Only count exported ones | |
| total_amount=float(total_amount_from_expenses), | |
| payment_groups=len(csv_rows), | |
| project_id=project_id, | |
| expense_ids=[e.id for e in exported_expenses] | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to queue export completion notification: {str(e)}") | |
| return StreamingResponse( | |
| iter([output.getvalue()]), | |
| media_type="text/csv", | |
| headers={ | |
| "Content-Disposition": f"attachment; filename={filename}", | |
| "X-Export-Count": str(len(csv_rows)), | |
| "X-Warnings-Count": str(len(warnings)) | |
| } | |
| ) | |
| def export_expenses_for_payment( | |
| from_date: date = Query(..., description="Start date (inclusive)"), | |
| to_date: date = Query(..., description="End date (inclusive)"), | |
| project_id: Optional[UUID] = Query(None, description="Filter by project"), | |
| ticket_id: Optional[UUID] = Query(None, description="Filter by ticket"), | |
| user_id: Optional[UUID] = Query(None, description="Filter by technician"), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Export approved unpaid expenses for payment processing. | |
| **Authorization:** PM, Dispatcher, Platform Admin only | |
| **What it does:** | |
| 1. Fetches all approved + unpaid expenses in date range | |
| 2. Groups by user + date (one payment per user per day) | |
| 3. Generates CSV with payment details | |
| 4. **Marks all expenses as paid** (irreversible!) | |
| **CSV Columns:** | |
| - Technician Name | |
| - Phone Number | |
| - Account Name | |
| - Total Amount | |
| - Expense Count | |
| - Date | |
| - Tickets Summary (detailed breakdown) | |
| - Categories Summary (totals by category) | |
| - Expense IDs (comma-separated UUIDs) | |
| **Grouping Logic:** | |
| - Agent payments: Grouped by user + date | |
| - Vendor payments: Separate row per expense | |
| **Side Effects:** | |
| - All exported expenses marked as `is_paid = true` | |
| - `paid_at` set to export timestamp | |
| - `payment_reference` set to `CSV_EXPORT_{timestamp}_{pm_user_id}` | |
| **Re-export Behavior:** | |
| - Safe to run multiple times | |
| - Only exports newly approved expenses (already paid excluded) | |
| **Example:** | |
| ``` | |
| POST /ticket-expenses/export-for-payment?from_date=2024-12-01&to_date=2024-12-08 | |
| ``` | |
| **Returns:** CSV file download | |
| """ | |
| from fastapi.responses import StreamingResponse | |
| import io | |
| import csv | |
| # Export expenses | |
| csv_rows, warnings = TicketExpenseService.export_for_payment( | |
| db=db, | |
| from_date=from_date, | |
| to_date=to_date, | |
| current_user=current_user, | |
| project_id=project_id, | |
| ticket_id=ticket_id, | |
| user_id=user_id | |
| ) | |
| # Check if export generated any rows | |
| if not csv_rows: | |
| # Build user-friendly error message | |
| error_summary = "Cannot export payments - please fix the following issues:" | |
| # Categorize warnings for better UX | |
| phone_issues = [w for w in warnings if "phone" in w.lower() or "invalid" in w.lower()] | |
| payment_issues = [w for w in warnings if "payment details" in w.lower() and w not in phone_issues] | |
| other_issues = [w for w in warnings if w not in phone_issues and w not in payment_issues] | |
| error_details = [] | |
| if phone_issues: | |
| error_details.append(f"📱 Phone Number Issues: {'; '.join(phone_issues)}") | |
| if payment_issues: | |
| error_details.append(f"💳 Payment Details Issues: {'; '.join(payment_issues)}") | |
| if other_issues: | |
| error_details.append(f"⚠️ Other Issues: {'; '.join(other_issues)}") | |
| full_error = f"{error_summary}\n\n" + "\n\n".join(error_details) | |
| logger.warning(f"Export failed: {error_summary}. Total warnings: {len(warnings)}") | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail=full_error | |
| ) | |
| # Generate Tende Pay CSV | |
| output = io.StringIO() | |
| if csv_rows: | |
| # Tende Pay column order (must match template exactly) | |
| fieldnames = [ | |
| "NAME", | |
| "ID NUMBER", | |
| "PHONE NUMBER", | |
| "AMOUNT", | |
| "PAYMENT MODE", | |
| "BANK (Optional)", | |
| "BANK ACCOUNT NO (Optional)", | |
| "PAYBILL BUSINESS NO (Optional)", | |
| "PAYBILL ACCOUNT NO (Optional)", | |
| "BUY GOODS TILL NO (Optional)", | |
| "BILL PAYMENT BILLER CODE (Optional)", | |
| "BILL PAYMENT ACCOUNT NO (Optional)", | |
| "NARRATION (OPTIONAL)" | |
| ] | |
| writer = csv.DictWriter(output, fieldnames=fieldnames) | |
| writer.writeheader() | |
| writer.writerows(csv_rows) | |
| # Add warnings as comments at the end | |
| if warnings: | |
| output.write("\n# WARNINGS:\n") | |
| for warning in warnings: | |
| output.write(f"# {warning}\n") | |
| # Prepare response | |
| output.seek(0) | |
| filename = f"tende_pay_expenses_{from_date.isoformat()}_{to_date.isoformat()}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv" | |
| return StreamingResponse( | |
| iter([output.getvalue()]), | |
| media_type="text/csv", | |
| headers={ | |
| "Content-Disposition": f"attachment; filename={filename}", | |
| "X-Export-Count": str(len(csv_rows)), | |
| "X-Warnings-Count": str(len(warnings)) | |
| } | |
| ) | |