Files
VoiceInvoiceAI/backend/services/pdf_service.py
2025-06-25 23:03:48 +05:30

145 lines
6.4 KiB
Python

# backend/services/pdf_service.py
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
# from reportlab.pdfgen import canvas # Not directly used with SimpleDocTemplate for main content
import os
from io import BytesIO
from datetime import datetime
from backend.models import InvoiceData
from backend.config import MINIO_PDF_BUCKET
from backend.services.storage_service import minio_storage_service # Import the MinIO service
def generate_invoice_pdf(invoice: InvoiceData) -> str:
"""
Generates a PDF invoice from the InvoiceData Pydantic model
and uploads it to the MinIO PDF bucket.
Args:
invoice (InvoiceData): The Pydantic model containing invoice data.
Returns:
str: The MinIO object name (path within the bucket) of the generated PDF.
"""
if not invoice.invoice_number:
# Generate a simple invoice number if not provided
invoice_number = f"INV-{invoice.invoice_date.replace('-', '') if invoice.invoice_date else datetime.now().strftime('%Y%m%d')}-{datetime.now().strftime('%H%M%S')}"
invoice.invoice_number = invoice_number
else:
invoice_number = invoice.invoice_number
# Sanitize client name for filename
sanitized_client_name = "".join(c for c in invoice.client_name if c.isalnum() or c in [' ', '_']).replace(' ', '_') if invoice.client_name else "unknown_client"
# Create a unique filename for the PDF in MinIO
pdf_object_name = f"invoice_{sanitized_client_name}_{invoice_number}_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf"
# Use BytesIO to create the PDF in memory
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4,
rightMargin=inch, leftMargin=inch,
topMargin=inch, bottomMargin=inch)
elements = []
styles = getSampleStyleSheet()
# --- Header ---
elements.append(Paragraph("<b>INVOICE</b>", styles['h1']))
elements.append(Spacer(1, 0.2 * inch))
elements.append(Paragraph(f"<b>Invoice #:</b> {invoice_number}", styles['Normal']))
elements.append(Paragraph(f"<b>Date:</b> {invoice.invoice_date if invoice.invoice_date else 'N/A'}", styles['Normal']))
elements.append(Paragraph(f"<b>Due Date:</b> {invoice.due_date if invoice.due_date else 'N/A'}", styles['Normal']))
elements.append(Spacer(1, 0.4 * inch))
# --- Client Information ---
elements.append(Paragraph("<b>Bill To:</b>", styles['h3']))
if invoice.client_name:
elements.append(Paragraph(invoice.client_name, styles['Normal']))
if invoice.client_address:
elements.append(Paragraph(invoice.client_address, styles['Normal']))
elements.append(Spacer(1, 0.4 * inch))
# --- Items Table ---
if invoice.items:
data = [['Description', 'Quantity', 'Unit Price', 'Total']]
for item in invoice.items:
# Ensure item totals are calculated if not present (though autofill_invoice_data handles this)
item_total = item.total if item.total is not None else (item.quantity * item.unit_price if item.quantity is not None and item.unit_price is not None else 0.0)
data.append([
item.description,
f"{item.quantity:.2f}",
f"${item.unit_price:.2f}",
f"${item_total:.2f}"
])
table_style = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#ADD8E6')), # Light blue header
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.HexColor('#F0F8FF')), # Alice blue rows
('GRID', (0, 0), (-1, -1), 1, colors.HexColor('#C0C0C0')), # Silver grid lines
('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#808080')), # Grey box border
('ALIGN', (1, 0), (-1, -1), 'RIGHT'), # Align Qty, Unit Price, Total to right
])
item_table = Table(data, colWidths=[3*inch, 0.8*inch, 1*inch, 1*inch])
item_table.setStyle(table_style)
elements.append(item_table)
elements.append(Spacer(1, 0.2 * inch))
# --- Totals ---
# Ensure all totals are float and not None before formatting
subtotal = invoice.subtotal if invoice.subtotal is not None else 0.0
tax_amount = invoice.tax_amount if invoice.tax_amount is not None else 0.0
grand_total = invoice.grand_total if invoice.grand_total is not None else 0.0
tax_rate_display = invoice.tax_rate * 100 if invoice.tax_rate is not None else 0.0
totals_data = [
['Subtotal:', f"${subtotal:.2f}"],
[f"Tax ({tax_rate_display:.0f}%):", f"${tax_amount:.2f}"],
['Grand Total:', f"${grand_total:.2f}"]
]
totals_table = Table(totals_data, colWidths=[4.8*inch, 1.2*inch])
totals_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'RIGHT'),
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]))
elements.append(totals_table)
elements.append(Spacer(1, 0.3 * inch))
# --- Notes ---
if invoice.notes:
elements.append(Paragraph("<b>Notes:</b>", styles['h3']))
elements.append(Paragraph(invoice.notes, styles['Normal']))
elements.append(Spacer(1, 0.2 * inch))
# --- Footer (Optional) ---
elements.append(Spacer(1, 0.5 * inch))
elements.append(Paragraph("<i>Thank you for your business!</i>", styles['Italic']))
# Build PDF in memory
try:
doc.build(elements)
buffer.seek(0) # Reset buffer position to the beginning
# Upload the PDF from memory to MinIO
minio_storage_service.upload_file(
bucket_name=MINIO_PDF_BUCKET,
object_name=pdf_object_name,
data=buffer,
length=buffer.tell(), # Get the current position (which is the file size)
content_type="application/pdf"
)
print(f"Invoice PDF generated and uploaded to MinIO: {MINIO_PDF_BUCKET}/{pdf_object_name}")
return pdf_object_name
except Exception as e:
print(f"Error generating or uploading PDF: {e}")
raise RuntimeError(f"Failed to generate or upload PDF: {e}")