swiftops-backend / src /app /services /invoice_viewing_service.py
kamau1's picture
Add missing organization name fields to InvoiceViewResponse and add debug logs to trace deployment issues
ff92c12
"""
Invoice Viewing Service - Public invoice viewing with token authentication
This service handles:
1. Viewing invoices by token (no authentication required)
2. Tracking views
3. Enriching invoice data with ticket details and images
"""
import logging
from typing import Dict
from uuid import UUID
from sqlalchemy.orm import Session
from fastapi import HTTPException
from datetime import datetime
from app.models.contractor_invoice import ContractorInvoice
from app.models.ticket import Ticket
from app.models.ticket_image import TicketImage
from app.models.document import Document
from app.models.ticket_status_history import TicketStatusHistory
from app.models.sales_order import SalesOrder
from app.models.project import ProjectRegion
from app.models.enums import TicketSource
logger = logging.getLogger(__name__)
class InvoiceViewingService:
"""Service for public invoice viewing"""
@staticmethod
def get_invoice_by_token(
db: Session,
token: str
) -> Dict:
"""
Get invoice details by viewing token.
Returns:
- Invoice details
- Ticket details with images
- Image URLs (Cloudinary public URLs)
"""
# Find invoice by token with eager loading of relationships
from sqlalchemy.orm import joinedload
invoice = db.query(ContractorInvoice).options(
joinedload(ContractorInvoice.contractor),
joinedload(ContractorInvoice.client),
joinedload(ContractorInvoice.project)
).filter(
ContractorInvoice.viewing_token == token,
ContractorInvoice.deleted_at.is_(None)
).first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
# Check token expiration
from datetime import timezone
now = datetime.now(timezone.utc)
if invoice.viewing_token_expires_at and invoice.viewing_token_expires_at < now:
raise HTTPException(status_code=403, detail="Viewing token has expired")
# Track view
invoice.times_viewed = (invoice.times_viewed or 0) + 1
invoice.last_viewed_at = now
db.commit()
# Get ticket IDs from line items
ticket_ids = [
UUID(item['ticket_id'])
for item in invoice.line_items
if item.get('type') == 'ticket' and item.get('ticket_id')
]
# Fetch tickets with details
tickets = db.query(Ticket).filter(
Ticket.id.in_(ticket_ids),
Ticket.deleted_at.is_(None)
).all()
# Build enriched line items
enriched_line_items = []
for line_item in invoice.line_items:
enriched_item = dict(line_item)
if line_item.get('type') == 'ticket' and line_item.get('ticket_id'):
ticket = next((t for t in tickets if str(t.id) == line_item['ticket_id']), None)
if ticket:
# Get images for this ticket
ticket_images = db.query(TicketImage).filter(
TicketImage.ticket_id == ticket.id,
TicketImage.deleted_at.is_(None)
).all()
# Build image data
images_data = []
for ti in ticket_images:
doc = db.query(Document).filter(Document.id == ti.document_id).first()
if doc:
images_data.append({
"id": str(ti.id),
"image_type": ti.image_type,
"description": ti.description,
"url": doc.file_url, # Direct URL (Cloudinary public)
"file_name": doc.file_name,
"captured_at": ti.captured_at.isoformat() if ti.captured_at else None
})
# Get status history
status_history = db.query(TicketStatusHistory).filter(
TicketStatusHistory.ticket_id == ticket.id,
TicketStatusHistory.deleted_at.is_(None)
).order_by(TicketStatusHistory.changed_at).all()
status_history_data = [
{
"old_status": sh.old_status,
"new_status": sh.new_status,
"changed_at": sh.changed_at.isoformat() if sh.changed_at else None,
"changed_by": sh.changed_by.name if sh.changed_by else None,
"change_reason": sh.change_reason
}
for sh in status_history
]
# Get sales order details if ticket is from sales order
sales_order_details = None
if ticket.source == TicketSource.SALES_ORDER.value and ticket.source_id:
sales_order = db.query(SalesOrder).filter(
SalesOrder.id == ticket.source_id,
SalesOrder.deleted_at.is_(None)
).first()
if sales_order:
sales_order_details = {
"order_number": sales_order.order_number,
"service_request_number": sales_order.service_request_number,
"customer_preferred_package": sales_order.customer_preferred_package,
"package_price": float(sales_order.package_price) if sales_order.package_price else None,
"installation_address_line1": sales_order.installation_address_line1,
"installation_address_line2": sales_order.installation_address_line2,
"installation_maps_link": sales_order.installation_maps_link,
"preferred_visit_date": sales_order.preferred_visit_date.isoformat() if sales_order.preferred_visit_date else None,
"preferred_visit_time": sales_order.preferred_visit_time,
"received_date": sales_order.received_date.isoformat() if sales_order.received_date else None,
"agent_name": sales_order.agent_name,
"agent_number": sales_order.agent_number
}
# Get region details
region_data = None
if ticket.project_region_id:
region = db.query(ProjectRegion).filter(
ProjectRegion.id == ticket.project_region_id,
ProjectRegion.deleted_at.is_(None)
).first()
if region:
region_data = {
"id": str(region.id),
"region_name": region.region_name,
"region_code": region.region_code
}
enriched_item['ticket_details'] = {
"id": str(ticket.id),
"ticket_name": ticket.ticket_name,
"ticket_type": ticket.ticket_type,
"work_description": ticket.work_description,
"completed_at": ticket.completed_at.isoformat() if ticket.completed_at else None,
"scheduled_date": ticket.scheduled_date.isoformat() if ticket.scheduled_date else None,
"work_location": {
"latitude": float(ticket.work_location_latitude) if ticket.work_location_latitude else None,
"longitude": float(ticket.work_location_longitude) if ticket.work_location_longitude else None
} if ticket.work_location_latitude else None,
"completion_data": ticket.completion_data or {},
"images": images_data,
"images_count": len(images_data),
"status_history": status_history_data,
"sales_order": sales_order_details,
"region": region_data
}
enriched_line_items.append(enriched_item)
# Get organization names
contractor_name = invoice.contractor.name if invoice.contractor else None
client_name = invoice.client.name if invoice.client else None
project_name = invoice.project.title if invoice.project else None
# Debug logging
logger.info(f"Invoice {invoice.invoice_number}: contractor={contractor_name}, client={client_name}, project={project_name}")
# Build response
return {
"invoice": {
"id": str(invoice.id),
"invoice_number": invoice.invoice_number,
"invoice_title": invoice.invoice_title,
"contractor_id": str(invoice.contractor_id),
"contractor_name": contractor_name,
"client_id": str(invoice.client_id),
"client_name": client_name,
"project_id": str(invoice.project_id) if invoice.project_id else None,
"project_name": project_name,
"issue_date": invoice.issue_date.isoformat(),
"due_date": invoice.due_date.isoformat(),
"subtotal": float(invoice.subtotal),
"tax_rate": float(invoice.tax_rate),
"tax_amount": float(invoice.tax_amount),
"discount_amount": float(invoice.discount_amount),
"total_amount": float(invoice.total_amount),
"amount_paid": float(invoice.amount_paid),
"amount_due": float(invoice.amount_due),
"currency": invoice.currency,
"status": invoice.status,
"notes": invoice.notes,
"terms_and_conditions": invoice.terms_and_conditions,
"line_items": enriched_line_items
},
"token_expires_at": invoice.viewing_token_expires_at.isoformat() if invoice.viewing_token_expires_at else None,
"times_viewed": invoice.times_viewed
}