swiftops-backend / src /app /api /v1 /ticket_expenses.py
kamau1's picture
refactor: remove reconciliation system and all related code, tasks, and docs
d12a170
"""
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
# ============================================
@router.post(
"",
response_model=TicketExpenseResponse,
status_code=status.HTTP_201_CREATED,
summary="Create ticket 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
# ============================================
@router.put(
"/{expense_id}",
response_model=TicketExpenseResponse,
summary="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
# ============================================
@router.post(
"/{expense_id}/approve",
response_model=TicketExpenseResponse,
summary="Approve or 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
# ============================================
@router.post(
"/{expense_id}/mark-paid",
response_model=TicketExpenseResponse,
summary="Mark expense 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
# ============================================
@router.put(
"/{expense_id}/payment-details",
response_model=TicketExpenseResponse,
summary="Update payment routing 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
# ============================================
@router.delete(
"/{expense_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="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
# ============================================
@router.get(
"/my-expenses",
response_model=TicketExpenseListResponse,
summary="Get current user's expenses"
)
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
)
@router.get(
"/my-stats",
response_model=TicketExpenseStats,
summary="Get current user's expense statistics"
)
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
# ============================================
@router.get(
"/stats",
response_model=TicketExpenseStats,
summary="Get expense 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
@router.get(
"/stats/summary",
response_model=TicketExpenseStats,
summary="Get expense statistics (detailed)"
)
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
# ============================================
@router.get(
"/{expense_id}",
response_model=TicketExpenseResponse,
summary="Get expense by ID"
)
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
# ============================================
@router.get(
"",
response_model=TicketExpenseListResponse,
summary="List expenses with filters"
)
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
# ============================================
@router.post(
"/bulk-approve",
summary="Bulk approve or reject expenses"
)
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
# ============================================
@router.post(
"/bulk-export",
summary="Bulk export specific expenses for payment (CSV)"
)
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))
}
)
@router.post(
"/export-for-payment",
summary="Export expenses for payment by date range (CSV)",
description="Export approved unpaid expenses as CSV and mark them as paid"
)
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))
}
)