Phase 1 OMS implementation: Invoicing: - Add Invoice and VendorInvoiceSettings database models - Full EU VAT support (27 countries, OSS, B2B reverse charge) - Invoice PDF generation with WeasyPrint + Jinja2 templates - Vendor invoice API endpoints for settings, creation, PDF download Subscription Tiers: - Add VendorSubscription model with 4 tiers (Essential/Professional/Business/Enterprise) - Tier limit enforcement for orders, products, team members - Feature gating based on subscription tier - Automatic trial subscription creation for new vendors - Integrate limit checks into order creation (direct and Letzshop sync) Marketing: - Update pricing documentation with 4-tier structure - Revise back-office positioning strategy - Update homepage with Veeqo-inspired Letzshop-focused messaging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
165 lines
5.2 KiB
Python
165 lines
5.2 KiB
Python
# 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.commit()
|
|
|
|
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.commit()
|
|
|
|
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()
|