# app/services/invoice_pdf_service.py """ Invoice PDF generation service using WeasyPrint. Renders HTML invoice templates to PDF using Jinja2 + WeasyPrint. Stores generated PDFs in the configured storage location. """ import logging import os from datetime import UTC, datetime from pathlib import Path from jinja2 import Environment, FileSystemLoader from sqlalchemy.orm import Session from app.core.config import settings from models.database.invoice import Invoice logger = logging.getLogger(__name__) # Template directory TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "invoices" # PDF storage directory (relative to project root) PDF_STORAGE_DIR = Path("storage") / "invoices" class InvoicePDFService: """Service for generating invoice PDFs.""" def __init__(self): """Initialize the PDF service with Jinja2 environment.""" self.env = Environment( loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=True, ) def _ensure_storage_dir(self, vendor_id: int) -> Path: """Ensure the storage directory exists for a vendor.""" storage_path = PDF_STORAGE_DIR / str(vendor_id) storage_path.mkdir(parents=True, exist_ok=True) return storage_path def _get_pdf_filename(self, invoice: Invoice) -> str: """Generate PDF filename for an invoice.""" # Sanitize invoice number for filename safe_number = invoice.invoice_number.replace("/", "-").replace("\\", "-") return f"{safe_number}.pdf" def generate_pdf( self, db: Session, invoice: Invoice, force_regenerate: bool = False, ) -> str: """ Generate PDF for an invoice. Args: db: Database session invoice: Invoice to generate PDF for force_regenerate: If True, regenerate even if PDF already exists Returns: Path to the generated PDF file """ # Check if PDF already exists if invoice.pdf_path and not force_regenerate: if Path(invoice.pdf_path).exists(): logger.debug(f"PDF already exists for invoice {invoice.invoice_number}") return invoice.pdf_path # Ensure storage directory exists storage_dir = self._ensure_storage_dir(invoice.vendor_id) pdf_filename = self._get_pdf_filename(invoice) pdf_path = storage_dir / pdf_filename # Render HTML template html_content = self._render_html(invoice) # Generate PDF using WeasyPrint try: from weasyprint import HTML html_doc = HTML(string=html_content, base_url=str(TEMPLATE_DIR)) html_doc.write_pdf(str(pdf_path)) logger.info(f"Generated PDF for invoice {invoice.invoice_number} at {pdf_path}") except ImportError: logger.error("WeasyPrint not installed. Install with: pip install weasyprint") raise RuntimeError("WeasyPrint not installed") except Exception as e: logger.error(f"Failed to generate PDF for invoice {invoice.invoice_number}: {e}") raise # Update invoice record with PDF path and timestamp invoice.pdf_path = str(pdf_path) invoice.pdf_generated_at = datetime.now(UTC) db.flush() return str(pdf_path) def _render_html(self, invoice: Invoice) -> str: """Render the invoice HTML template.""" template = self.env.get_template("invoice.html") # Prepare template context context = { "invoice": invoice, "seller": invoice.seller_details, "buyer": invoice.buyer_details, "line_items": invoice.line_items, "bank_details": invoice.bank_details, "payment_terms": invoice.payment_terms, "footer_text": invoice.footer_text, "now": datetime.now(UTC), } return template.render(**context) def get_pdf_path(self, invoice: Invoice) -> str | None: """Get the PDF path for an invoice if it exists.""" if invoice.pdf_path and Path(invoice.pdf_path).exists(): return invoice.pdf_path return None def delete_pdf(self, invoice: Invoice, db: Session) -> bool: """ Delete the PDF file for an invoice. Args: invoice: Invoice whose PDF to delete db: Database session Returns: True if deleted, False if not found """ if not invoice.pdf_path: return False pdf_path = Path(invoice.pdf_path) if pdf_path.exists(): try: pdf_path.unlink() logger.info(f"Deleted PDF for invoice {invoice.invoice_number}") except Exception as e: logger.error(f"Failed to delete PDF {pdf_path}: {e}") return False # Clear PDF fields invoice.pdf_path = None invoice.pdf_generated_at = None db.flush() return True def regenerate_pdf(self, db: Session, invoice: Invoice) -> str: """Force regenerate PDF for an invoice.""" return self.generate_pdf(db, invoice, force_regenerate=True) # Singleton instance invoice_pdf_service = InvoicePDFService()