swiftops-backend / src /app /api /v1 /invoice_generation.py
kamau1's picture
feat: unified sync notification system, realtime timesheets and payroll, simplified project role compensation
95005e1
"""
Invoice Generation API Endpoints
Endpoints for:
- Getting available tickets for invoicing
- Generating invoices from tickets
- Exporting invoices to CSV
- Regenerating viewing tokens
"""
from fastapi import APIRouter, Depends, HTTPException, Response, Query, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
from uuid import UUID
from datetime import date
import os
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.invoice_generation_service import InvoiceGenerationService
from app.services.notification_delivery import NotificationDelivery
from app.schemas.invoice_generation import (
InvoiceGenerateRequest,
AvailableTicketResponse,
InvoiceGenerateResponse,
RegenerateTokenRequest,
RegenerateTokenResponse
)
from app.schemas.contractor_invoice import ContractorInvoiceResponse
router = APIRouter(prefix="/invoices", tags=["Invoice Generation"])
def check_invoice_permission(current_user: User, contractor_id: UUID):
"""Check if user can manage invoices for contractor"""
if current_user.role == AppRole.PLATFORM_ADMIN.value:
return True
if current_user.role in [AppRole.PROJECT_MANAGER.value, AppRole.DISPATCHER.value]:
if current_user.contractor_id == contractor_id:
return True
raise HTTPException(
status_code=403,
detail="Not authorized to manage invoices for this contractor"
)
@router.get("/available-tickets", response_model=dict)
def get_available_tickets(
project_id: UUID = Query(..., description="Project ID (required)"),
contractor_id: Optional[str] = Query(None, description="Contractor ID (optional - use 'auto' or omit to derive from project)"),
start_date: Optional[date] = Query(None, description="Filter by completion date (start)"),
end_date: Optional[date] = Query(None, description="Filter by completion date (end)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get completed tickets available for invoicing.
Returns tickets that are:
- Status: completed
- Not yet invoiced
- Optionally filtered by project and date range
**Contractor ID:**
- If not provided, automatically derived from project's contractor
- If provided, validates it matches the project's contractor
"""
from app.models.project import Project
# Get project to derive contractor
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Handle contractor_id parameter
resolved_contractor_id: UUID
if contractor_id is None or contractor_id.lower() == "auto":
# Auto-derive from project
resolved_contractor_id = project.contractor_id
if not resolved_contractor_id:
raise HTTPException(
status_code=400,
detail="Project has no contractor assigned. Please specify contractor_id."
)
else:
# Parse and validate provided contractor_id
try:
resolved_contractor_id = UUID(contractor_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid contractor_id format: {contractor_id}. Use a valid UUID or 'auto'."
)
# Validate it matches project's contractor
if resolved_contractor_id != project.contractor_id:
raise HTTPException(
status_code=400,
detail=f"Contractor {resolved_contractor_id} does not match project's contractor {project.contractor_id}"
)
check_invoice_permission(current_user, resolved_contractor_id)
tickets = InvoiceGenerationService.get_available_tickets(
db=db,
contractor_id=resolved_contractor_id,
project_id=project_id,
start_date=start_date,
end_date=end_date
)
return {
"tickets": tickets,
"total_count": len(tickets)
}
@router.post("/generate", response_model=InvoiceGenerateResponse)
def generate_invoice(
data: InvoiceGenerateRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Generate invoice from selected completed tickets.
This creates:
- Invoice record with ticket line items
- Viewing token for public access
- Notifications for all PMs
- Audit log entry
Returns invoice details and links for viewing/downloading.
**Auto-derivation:**
- contractor_id: Derived from project if not provided
- client_id: Derived from project if not provided
"""
from app.models.project import Project
# Get project to derive contractor and client
project = db.query(Project).filter(Project.id == data.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Derive contractor_id if not provided
contractor_id = data.contractor_id
if contractor_id is None:
contractor_id = project.contractor_id
if not contractor_id:
raise HTTPException(
status_code=400,
detail="Project has no contractor assigned. Please specify contractor_id."
)
else:
# Validate provided contractor_id matches project
if contractor_id != project.contractor_id:
raise HTTPException(
status_code=400,
detail=f"Contractor {contractor_id} does not match project's contractor {project.contractor_id}"
)
# Derive client_id if not provided
client_id = data.client_id
if client_id is None:
client_id = project.client_id
if not client_id:
raise HTTPException(
status_code=400,
detail="Project has no client assigned. Please specify client_id."
)
else:
# Validate provided client_id matches project
if client_id != project.client_id:
raise HTTPException(
status_code=400,
detail=f"Client {client_id} does not match project's client {project.client_id}"
)
check_invoice_permission(current_user, contractor_id)
invoice, viewing_link, csv_link, notification_ids = InvoiceGenerationService.generate_invoice_from_tickets(
db=db,
contractor_id=contractor_id,
client_id=client_id,
project_id=data.project_id,
ticket_ids=data.ticket_ids,
invoice_metadata=data.dict(exclude={'ticket_ids', 'contractor_id', 'client_id', 'project_id'}),
current_user=current_user
)
# Queue notification delivery (Tier 2 - Asynchronous)
if notification_ids:
NotificationDelivery.queue_bulk_delivery(
background_tasks=background_tasks,
notification_ids=notification_ids
)
return InvoiceGenerateResponse(
success=True,
invoice_id=invoice.id,
invoice_number=invoice.invoice_number,
tickets_count=len(data.ticket_ids),
viewing_link=viewing_link,
csv_download_link=csv_link,
notification_created=True
)
@router.get("/{invoice_id}/export/csv")
def export_invoice_csv(
invoice_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Export invoice to CSV with ticket details and image links.
CSV includes:
- Invoice metadata
- Ticket details (ID, name, type, description)
- Sales order references
- Completion data
- Image links (type:url format)
Marks invoice as exported and tracks who exported it.
"""
csv_data = InvoiceGenerationService.export_invoice_to_csv(
db=db,
invoice_id=invoice_id,
current_user=current_user
)
return Response(
content=csv_data.getvalue(),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=invoice_{invoice_id}.csv"
}
)
@router.post("/{invoice_id}/regenerate-token", response_model=RegenerateTokenResponse)
def regenerate_token(
invoice_id: UUID,
data: RegenerateTokenRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Regenerate viewing token for invoice.
Use cases:
- Token expired
- Client lost the link
- Need to extend access period
Returns new viewing link with updated expiration.
"""
token, expires_at = InvoiceGenerationService.regenerate_viewing_token(
db=db,
invoice_id=invoice_id,
expires_in_days=data.expires_in_days,
current_user=current_user
)
base_url = os.getenv("APP_BASE_URL", "https://your-app.com")
viewing_link = f"{base_url}/invoices/view?token={token}"
return RegenerateTokenResponse(
viewing_link=viewing_link,
expires_at=expires_at
)