""" PDF Generation utility for invoices Uses WeasyPrint to generate professional PDF invoices """ import os import html as html_lib from datetime import datetime from weasyprint import HTML, CSS from weasyprint.text.fonts import FontConfiguration from app.models import Settings from flask import current_app from flask_babel import gettext as _ try: from babel.dates import format_date as babel_format_date except Exception: babel_format_date = None from pathlib import Path from flask import render_template class InvoicePDFGenerator: """Generate PDF invoices with company branding""" def __init__(self, invoice, settings=None): self.invoice = invoice self.settings = settings or Settings.get_settings() def generate_pdf(self): """Generate PDF content and return as bytes""" use_custom_html = bool((self.settings.invoice_pdf_template_html or '').strip()) use_custom_css = bool((self.settings.invoice_pdf_template_css or '').strip()) if use_custom_html or use_custom_css: html_content, css_content = self._render_from_custom_template() else: html_content = self._generate_html() css_content = self._generate_css() # Configure fonts font_config = FontConfiguration() # Create PDF (avoid passing unexpected args to PDF class) base_url = None try: base_url = current_app.root_path except Exception: base_url = None html_doc = HTML(string=html_content, base_url=base_url) css_doc = CSS(string=css_content, font_config=font_config) pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc], font_config=font_config) return pdf_bytes def _render_from_custom_template(self): """Render HTML and CSS from custom templates stored in settings, with fallback to default template.""" html_template = (self.settings.invoice_pdf_template_html or '').strip() css_template = (self.settings.invoice_pdf_template_css or '').strip() html = '' if css_template: css = css_template else: try: from flask import render_template as _render_tpl css = _render_tpl('invoices/pdf_styles_default.css') except Exception: css = self._generate_css() try: # Render using Flask's Jinja environment to include app filters and _() if html_template: from flask import render_template_string html = render_template_string(html_template, invoice=self.invoice, settings=self.settings, Path=Path) except Exception: html = '' if not html: try: html = render_template('invoices/pdf_default.html', invoice=self.invoice, settings=self.settings, Path=Path) except Exception: html = f"

{_('Invoice')} {self.invoice.invoice_number}

" return html, css def _generate_html(self): """Generate HTML content for the invoice""" html = f""" {_('Invoice')} {self.invoice.invoice_number}
{self._get_company_logo_html()}

{self._escape(self.settings.company_name)}

{self._nl2br(self.settings.company_address)} {_('Email')}: {self._escape(self.settings.company_email)} ยท {_('Phone')}: {self._escape(self.settings.company_phone)} {_('Website')}: {self._escape(self.settings.company_website)} {self._get_company_tax_info()}
{_('INVOICE')}
{_('Invoice #')}
{self.invoice.invoice_number}
{_('Issue Date')}
{(babel_format_date(self.invoice.issue_date) if babel_format_date else self.invoice.issue_date.strftime('%Y-%m-%d'))}
{_('Due Date')}
{(babel_format_date(self.invoice.due_date) if babel_format_date else self.invoice.due_date.strftime('%Y-%m-%d'))}
{_('Status')}
{_(self.invoice.status.title())}
{_('Bill To')}
{self._escape(self.invoice.client_name)}
{self._get_client_email_html()} {self._get_client_address_html()}
{_('Project')}
{self._escape(self.invoice.project.name)}
{self._get_project_description_html()}
{self._generate_items_rows()} {self._generate_totals_rows()}
{_('Description')} {_('Quantity (Hours)')} {_('Unit Price')} {_('Total Amount')}
{self._get_additional_info_html()}
""" return html def _escape(self, value): return html_lib.escape(value) if value else '' def _nl2br(self, value): if not value: return '' return self._escape(value).replace('\n', '
') def _get_company_logo_html(self): """Generate HTML for company logo if available""" if self.settings.has_logo(): logo_path = self.settings.get_logo_path() if logo_path and os.path.exists(logo_path): # Build a cross-platform file URI (handles Windows and POSIX paths) try: file_url = Path(logo_path).resolve().as_uri() except Exception: # Fallback to naive file:// if as_uri fails file_url = f'file://{logo_path}' return f'' return '' def _get_company_tax_info(self): """Generate HTML for company tax information""" if self.settings.company_tax_id: return f'
Tax ID: {self.settings.company_tax_id}
' return '' def _get_client_email_html(self): """Generate HTML for client email if available""" if self.invoice.client_email: return f'
{self.invoice.client_email}
' return '' def _get_client_address_html(self): """Generate HTML for client address if available""" if self.invoice.client_address: return f'
{self.invoice.client_address}
' return '' def _get_project_description_html(self): """Generate HTML for project description if available""" if self.invoice.project.description: return f'
{self.invoice.project.description}
' return '' def _generate_items_rows(self): """Generate HTML rows for invoice items""" rows = [] for item in self.invoice.items: row = f""" {self._escape(item.description)} {self._get_time_entry_info_html(item)} {item.quantity:.2f} {self._format_currency(item.unit_price)} {self._format_currency(item.total_amount)} """ rows.append(row) return ''.join(rows) def _get_time_entry_info_html(self, item): """Generate HTML for time entry information if available""" if item.time_entry_ids: count = len(item.time_entry_ids.split(',')) return f'
Generated from {count} time entries' return '' def _generate_totals_rows(self): """Generate HTML rows for invoice totals""" rows = [] # Subtotal rows.append(f""" Subtotal: {self._format_currency(self.invoice.subtotal)} """) # Tax if applicable if self.invoice.tax_rate > 0: rows.append(f""" Tax ({self.invoice.tax_rate:.2f}%): {self._format_currency(self.invoice.tax_amount)} """) # Total rows.append(f""" Total Amount: {self._format_currency(self.invoice.total_amount)} """) return ''.join(rows) def _get_additional_info_html(self): """Generate HTML for additional invoice information""" html_parts = [] if self.invoice.notes: html_parts.append(f"""

{_('Notes:')}

{self.invoice.notes}

""") if self.invoice.terms: html_parts.append(f"""

{_('Terms:')}

{self.invoice.terms}

""") if html_parts: return f'
{"".join(html_parts)}
' return '' def _format_currency(self, value): """Format numeric currency with thousands separators and 2 decimals.""" try: return f"{float(value):,.2f} {self.settings.currency}" except Exception: return f"{value} {self.settings.currency}" def _get_payment_info_html(self): """Generate HTML for payment information""" if self.settings.company_bank_info: return f"""

{_('Payment Information:')}

{self.settings.company_bank_info}
""" return '' def _generate_css(self): """Generate CSS styles for the invoice""" return """ @page { size: A4; margin: 2cm; @bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 10pt; color: #666; } } body { font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 12pt; line-height: 1.4; color: #333; margin: 0; padding: 0; } .invoice-container { max-width: 100%; } .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 2em; border-bottom: 2px solid #007bff; padding-bottom: 1em; } .company-info { flex: 1; } .company-logo { max-width: 150px; max-height: 80px; display: block; margin-left: auto; margin-right: 0; margin-bottom: 1em; } .company-name { font-size: 24pt; font-weight: bold; color: #007bff; margin: 0 0 0.5em 0; } .company-address { margin-bottom: 0.5em; line-height: 1.3; } .company-contact { margin-bottom: 0.5em; } .company-contact span { display: block; margin-bottom: 0.2em; font-size: 10pt; } .company-tax { font-size: 10pt; color: #666; } .invoice-info { text-align: right; min-width: 200px; } .logo-container { text-align: right; margin-bottom: 1em; } .invoice-title { font-size: 28pt; font-weight: bold; color: #007bff; margin: 0 0 1em 0; } .invoice-details .detail-row { margin-bottom: 0.5em; } .detail-row .label { font-weight: bold; margin-right: 0.5em; } .status-draft { color: #6c757d; } .status-sent { color: #17a2b8; } .status-paid { color: #28a745; } .status-overdue { color: #dc3545; } .status-cancelled { color: #343a40; } .client-section, .project-section { margin-bottom: 2em; } .client-section h3, .project-section h3 { font-size: 14pt; font-weight: bold; color: #007bff; margin: 0 0 0.5em 0; border-bottom: 1px solid #dee2e6; padding-bottom: 0.3em; } .client-name { font-weight: bold; font-size: 14pt; margin-bottom: 0.5em; } .client-email, .client-address, .project-description { margin-bottom: 0.3em; color: #666; } .items-section { margin-bottom: 2em; } .invoice-table { width: 100%; border-collapse: collapse; margin-bottom: 1em; } .invoice-table th, .invoice-table td { border: 1px solid #dee2e6; padding: 0.75em; text-align: left; } .invoice-table th { background-color: #f8f9fa; font-weight: bold; color: #495057; } .description { width: 40%; } .quantity { width: 15%; text-align: center; } .unit-price { width: 20%; text-align: right; } .total { width: 25%; text-align: right; } .text-center { text-align: center; } .text-right { text-align: right; } .time-entry-info { color: #6c757d; font-style: italic; } .subtotal { background-color: #f8f9fa; } .tax { background-color: #fff3cd; } .total { background-color: #d1ecf1; font-weight: bold; } .additional-info { margin-bottom: 2em; } .notes-section, .terms-section { margin-bottom: 1em; } .notes-section h4, .terms-section h4 { font-size: 12pt; font-weight: bold; color: #495057; margin: 0 0 0.5em 0; } .footer { margin-top: 2em; padding-top: 1em; border-top: 1px solid #dee2e6; } .payment-info { margin-bottom: 1em; } .payment-info h4 { font-size: 12pt; font-weight: bold; color: #495057; margin: 0 0 0.5em 0; } .bank-info { color: #666; line-height: 1.3; } .terms h4 { font-size: 12pt; font-weight: bold; color: #495057; margin: 0 0 0.5em 0; } .terms p { color: #666; line-height: 1.3; } /* Utility classes */ .nl2br { white-space: pre-line; } """