""" PDF/A-3 Generation Service for ZATCA E-Invoice This module handles: - PDF/A-3 generation with embedded XML - QR code image generation and embedding - Bilingual invoice layout (Arabic + English) - ZATCA-compliant invoice formatting - Dynamic color theming from tenant settings """ import base64 import io import logging from typing import Optional import qrcode import arabic_reshaper from bidi.algorithm import get_display from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm from reportlab.pdfgen import canvas from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image, PageBreak from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.lib.enums import TA_CENTER, TA_RIGHT, TA_LEFT from django.conf import settings from core.settings_service import get_tenant_settings_service from finance.models import Invoice logger = logging.getLogger(__name__) # Register Arabic font try: pdfmetrics.registerFont(TTFont('Arabic', '/System/Library/Fonts/SFArabic.ttf')) ARABIC_FONT_AVAILABLE = True logger.info("Arabic font registered successfully") except Exception as e: ARABIC_FONT_AVAILABLE = False logger.warning(f"Could not register Arabic font: {e}") def reshape_arabic(text: str) -> str: """ Reshape Arabic text for proper display in PDF. Args: text: Text containing Arabic characters Returns: str: Reshaped text ready for PDF rendering """ try: reshaped_text = arabic_reshaper.reshape(text) bidi_text = get_display(reshaped_text) return bidi_text except Exception as e: logger.warning(f"Error reshaping Arabic text: {e}") return text class PDFService: """Service for generating PDF/A-3 invoices.""" @staticmethod def generate_qr_code_image(qr_data: str) -> io.BytesIO: """ Generate QR code image from data. Args: qr_data: QR code data (base64 TLV encoded) Returns: io.BytesIO: QR code image buffer """ try: # Create QR code qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4, ) qr.add_data(qr_data) qr.make(fit=True) # Create image img = qr.make_image(fill_color="black", back_color="white") # Save to buffer buffer = io.BytesIO() img.save(buffer, format='PNG') buffer.seek(0) return buffer except Exception as e: logger.error(f"Error generating QR code image: {e}") raise @staticmethod def _get_tenant_colors(invoice): """ Get primary and secondary colors from tenant settings. Args: invoice: Invoice instance Returns: tuple: (primary_color, secondary_color) as HexColor objects """ try: settings_service = get_tenant_settings_service(invoice.tenant) primary_color_hex = settings_service.get_setting('basic_primary_color', '#8B5CF6') secondary_color_hex = settings_service.get_setting('basic_secondary_color', '#6366F1') # Ensure hex format if not primary_color_hex.startswith('#'): primary_color_hex = '#8B5CF6' if not secondary_color_hex.startswith('#'): secondary_color_hex = '#6366F1' return ( colors.HexColor(primary_color_hex), colors.HexColor(secondary_color_hex) ) except Exception as e: logger.warning(f"Error getting tenant colors: {e}, using defaults") return (colors.HexColor('#8B5CF6'), colors.HexColor('#6366F1')) @staticmethod def generate_invoice_pdf(invoice, include_xml: bool = True) -> bytes: """ Generate professional ZATCA-compliant invoice PDF with tenant branding. Args: invoice: Invoice instance include_xml: Whether to embed XML in PDF Returns: bytes: PDF content """ try: # Get tenant colors primary_color, secondary_color = PDFService._get_tenant_colors(invoice) # Create PDF buffer buffer = io.BytesIO() # Create PDF document doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=15*mm, leftMargin=15*mm, topMargin=10*mm, bottomMargin=10*mm ) # Container for PDF elements elements = [] # Styles styles = getSampleStyleSheet() # ===== HEADER SECTION ===== ar_vat_invoice = reshape_arabic("فاتورة ضريبية") ar_invoice_no = reshape_arabic("رقم الفاتورة") ar_invoice_date = reshape_arabic("تاريخ الفاتورة") ar_due = reshape_arabic("تاريخ الاستحقاق") ar_bill_to = reshape_arabic("الفاتورة إلى") # Get logo from tenant settings settings_service = get_tenant_settings_service(invoice.tenant) logo_file = settings_service.get_setting('basic_logo') # Create logo and company info section (left side) if logo_file and hasattr(logo_file, 'path'): try: logo_img = Image(logo_file.path, width=25*mm, height=25*mm, kind='proportional') except Exception as e: logger.warning(f"Could not load logo image: {e}, using placeholder") logo_img = None else: logo_img = None # Company info below logo company_parts = [f'{reshape_arabic(invoice.tenant.name_ar)}
'] if invoice.tenant.vat_number: company_parts.append(f'VAT: {invoice.tenant.vat_number}
') if invoice.tenant.address: company_parts.append(f'{invoice.tenant.address}
') if invoice.tenant.city: company_parts.append(f'{invoice.tenant.city}') company_html = ''.join(company_parts) company_para = Paragraph(company_html, ParagraphStyle('Company', parent=styles['Normal'])) # QR Code (below company info on left) qr_img = None if invoice.qr_code: try: qr_buffer = PDFService.generate_qr_code_image(invoice.qr_code) qr_img = Image(qr_buffer, width=35 * mm, height=35 * mm) except Exception as e: logger.warning(f"Could not add QR code: {e}") # Left column content left_column_data = [] if logo_img: left_column_data.append([logo_img]) left_column_data.append([company_para]) if qr_img: left_column_data.append([qr_img]) left_column = Table(left_column_data, colWidths=[80*mm]) left_column.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 5), ('BOTTOMPADDING', (0, 0), (-1, -1), 5), ])) # Right column: Title and invoice details title_html = ( f'' f'VAT Invoice

' f'{ar_vat_invoice}



' f'
' ) title_para = Paragraph(title_html, ParagraphStyle('Title', parent=styles['Normal'])) # Invoice details invoice_details_html = ( f'' f'Invoice no. - {ar_invoice_no}
' f'{invoice.invoice_number}

' f'Invoice date - {ar_invoice_date}
' f'{invoice.issue_date.strftime("%d.%m.%Y")}

' f'Due - {ar_due}
' f'{invoice.due_date.strftime("%d.%m.%Y")}' f'
' ) invoice_details_para = Paragraph(invoice_details_html, ParagraphStyle('InvDetails', parent=styles['Normal'])) # Bill to section bill_to_html = ( f'' f'Bill to - {ar_bill_to}
' f'{reshape_arabic(invoice.patient.full_name_ar)}
' ) if invoice.patient.email: bill_to_html += f'{invoice.patient.email}
' if invoice.patient.phone: bill_to_html += f'{invoice.patient.phone}' bill_to_html += '
' bill_to_para = Paragraph(bill_to_html, ParagraphStyle('BillTo', parent=styles['Normal'])) # Right column content right_column_data = [ [title_para], [invoice_details_para], [bill_to_para] ] right_column = Table(right_column_data, colWidths=[110*mm]) right_column.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('ALIGN', (0, 0), (-1, -1), 'RIGHT'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 5), ('BOTTOMPADDING', (0, 0), (-1, -1), 10), ])) # Combine left and right columns header_table = Table([[left_column, right_column]], colWidths=[60*mm, 110*mm]) header_table.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ])) elements.append(header_table) elements.append(Spacer(1, 10*mm)) # ===== LINE ITEMS TABLE ===== ar_description = reshape_arabic("الوصف") ar_rate = reshape_arabic("السعر") ar_qty = reshape_arabic("الكمية") ar_tax = reshape_arabic("الضريبة") ar_disc = reshape_arabic("الخصم") ar_amount = reshape_arabic("المبلغ") # Saudi Riyal symbol sar_symbol = "ر.س" line_items_data = [ [ Paragraph(f'Desc.
{ar_description}', ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_LEFT, textColor=colors.white)), Paragraph(f'Price
{ar_rate}', ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_RIGHT, textColor=colors.white)), Paragraph(f'QTY
{ar_qty}', ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER, textColor=colors.white)), Paragraph(f'VAT
{ar_tax}', ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER, textColor=colors.white)), Paragraph(f'Discount
{ar_disc}', ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER, textColor=colors.white)), Paragraph(f'Total
{ar_amount}', ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_RIGHT, textColor=colors.white)) ] ] for item in invoice.line_items.all(): # Calculate VAT percentage vat_percentage = "15" if invoice.tax > 0 else "0" line_items_data.append([ Paragraph(item.description or 'Service', ParagraphStyle('Cell', parent=styles['Normal'], fontSize=9)), Paragraph(f'{item.unit_price:.2f}', ParagraphStyle('Cell', parent=styles['Normal'], fontSize=9, alignment=TA_RIGHT)), Paragraph(str(item.quantity), ParagraphStyle('Cell', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER)), Paragraph(vat_percentage, ParagraphStyle('Cell', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER)), Paragraph('0', ParagraphStyle('Cell', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER)), Paragraph(f'{item.total:.2f}', ParagraphStyle('Cell', parent=styles['Normal'], fontSize=9, alignment=TA_RIGHT)) ]) items_table = Table(line_items_data, colWidths=[60*mm, 25*mm, 15*mm, 18*mm, 18*mm, 34*mm]) items_table.setStyle(TableStyle([ # Header with orange background ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#FFA500')), ('TEXTCOLOR', (0, 0), (-1, -1), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 9), ('TOPPADDING', (0, 0), (-1, 0), 8), ('BOTTOMPADDING', (0, 0), (-1, 0), 8), # Data rows ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'), ('FONTSIZE', (0, 1), (-1, -1), 9), ('TEXTCOLOR', (0, 1), (-1, -1), colors.black), ('TOPPADDING', (0, 1), (-1, -1), 6), ('BOTTOMPADDING', (0, 1), (-1, -1), 6), ('LEFTPADDING', (0, 0), (-1, -1), 5), ('RIGHTPADDING', (0, 0), (-1, -1), 5), # Alternating row colors ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.HexColor('#FFF8E1'), colors.white]), # Borders ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#FFA500')), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ])) elements.append(items_table) elements.append(Spacer(1, 10*mm)) # ===== PAYMENT INSTRUCTION AND TOTALS SECTION ===== # ar_payment_inst = reshape_arabic("تعليمات الدفع") ar_subtotal = reshape_arabic("المجموع الفرعي") ar_discount = reshape_arabic("الخصم") # ar_shipping = reshape_arabic("تكلفة الشحن") ar_sales_tax = reshape_arabic("ضريبة القيمة المضافة") ar_total = reshape_arabic("الإجمالي") ar_amount_paid = reshape_arabic("المبلغ المدفوع") ar_balance_due = reshape_arabic("الرصيد المستحق") # Payment instruction (left) - show actual payment details payment_inst_parts = [ # f'Payment instruction
' # f'{ar_payment_inst}

' ] # Get payment information payments = invoice.payments.filter(status='COMPLETED').order_by('-payment_date') if payments.exists(): for payment in payments: payment_method = payment.get_method_display() payment_inst_parts.append( f'{payment_method}
' f'Amount: {payment.amount:.2f}
' f'Date: {payment.payment_date.strftime("%d.%m.%Y")}
' ) if payment.reference: payment_inst_parts.append(f'Ref: {payment.reference}
') payment_inst_parts.append('
') else: # No payments yet payment_inst_parts.append( f'Bank Transfer Details:
' f'Account: {invoice.tenant.name}
' f'VAT: {invoice.tenant.vat_number or "N/A"}
' ) payment_inst_html = ''.join(payment_inst_parts) # Totals (right) - using Saudi Riyal symbol totals_html_parts = [f'', f'Subtotal - {ar_subtotal}
', f'{invoice.subtotal:.2f}

'] if invoice.discount > 0: totals_html_parts.append(f'Discount - {ar_discount}
') totals_html_parts.append(f'{invoice.discount:.2f}

') # totals_html_parts.append(f'Shipping Cost, {sar_symbol}: 0.00
') # totals_html_parts.append(f'{ar_shipping}
') totals_html_parts.append(f'VAT - {ar_sales_tax}
') totals_html_parts.append(f'{invoice.tax:.2f}

') totals_html_parts.append(f'Total - {ar_total}
') totals_html_parts.append(f'{invoice.total:.2f}

') totals_html_parts.append(f'Amount paid - {ar_amount_paid}
') totals_html_parts.append(f'{invoice.amount_paid:.2f}

') totals_html_parts.append(f'Balance Due - {ar_balance_due}
') totals_html_parts.append(f'{invoice.amount_due:.2f}') totals_html_parts.append('
') totals_html = ''.join(totals_html_parts) payment_totals_section = Table([ [Paragraph(payment_inst_html, ParagraphStyle('PayInst', parent=styles['Normal'])), Paragraph(totals_html, ParagraphStyle('Totals', parent=styles['Normal']))] ], colWidths=[70*mm, 100*mm]) payment_totals_section.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ])) elements.append(payment_totals_section) # Build PDF doc.build(elements) # Get PDF content pdf_content = buffer.getvalue() buffer.close() # If include_xml, embed XML (requires additional library) if include_xml and invoice.xml_content: pdf_content = PDFService._embed_xml_in_pdf(pdf_content, invoice.xml_content) logger.info(f"PDF generated for invoice {invoice.invoice_number}") return pdf_content except Exception as e: logger.error(f"Error generating PDF: {e}") raise @staticmethod def _embed_xml_in_pdf(pdf_content: bytes, xml_content: str) -> bytes: """ Embed XML content in PDF as attachment (PDF/A-3 requirement). Args: pdf_content: Original PDF content xml_content: XML content to embed Returns: bytes: PDF with embedded XML """ try: # This requires PyPDF2 or similar library # For now, return original PDF # TODO: Implement XML embedding using PyPDF2 logger.warning("XML embedding not yet implemented - returning PDF without embedded XML") return pdf_content except Exception as e: logger.error(f"Error embedding XML in PDF: {e}") return pdf_content @staticmethod def generate_invoice_pdf_arabic(invoice) -> bytes: """ Generate Arabic-first invoice PDF. Args: invoice: Invoice instance Returns: bytes: PDF content """ try: # Create PDF buffer buffer = io.BytesIO() # Create canvas for RTL support c = canvas.Canvas(buffer, pagesize=A4) width, height = A4 # Use Arabic font if available if ARABIC_FONT_AVAILABLE: c.setFont('Arabic', 12) else: c.setFont('Helvetica', 12) # Header c.setFont('Helvetica-Bold', 20) ar_title = reshape_arabic("فاتورة ضريبية") c.drawCentredString(width/2, height - 50, ar_title) c.setFont('Helvetica', 16) c.drawCentredString(width/2, height - 75, "TAX INVOICE") # Invoice details (RTL for Arabic) y = height - 120 if ARABIC_FONT_AVAILABLE: c.setFont('Arabic', 10) else: c.setFont('Helvetica', 10) # Right-aligned for Arabic ar_inv_num = reshape_arabic(f"رقم الفاتورة: {invoice.invoice_number}") c.drawRightString(width - 50, y, ar_inv_num) y -= 20 ar_date = reshape_arabic(f"التاريخ: {invoice.issue_date}") c.drawRightString(width - 50, y, ar_date) y -= 20 ar_type = reshape_arabic(f"نوع الفاتورة: {invoice.get_invoice_type_display()}") c.drawRightString(width - 50, y, ar_type) # Seller information y -= 40 c.setFont('Helvetica-Bold', 12) ar_seller = reshape_arabic("معلومات البائع") c.drawRightString(width - 50, y, ar_seller) y -= 20 if ARABIC_FONT_AVAILABLE: c.setFont('Arabic', 10) else: c.setFont('Helvetica', 10) ar_name = reshape_arabic(f"الاسم: {invoice.tenant.name_ar or invoice.tenant.name}") c.drawRightString(width - 50, y, ar_name) y -= 20 ar_vat = reshape_arabic(f"الرقم الضريبي: {invoice.tenant.vat_number or 'N/A'}") c.drawRightString(width - 50, y, ar_vat) # Customer information y -= 40 c.setFont('Helvetica-Bold', 12) ar_customer = reshape_arabic("معلومات العميل") c.drawRightString(width - 50, y, ar_customer) y -= 20 if ARABIC_FONT_AVAILABLE: c.setFont('Arabic', 10) else: c.setFont('Helvetica', 10) ar_cust_name = reshape_arabic(f"الاسم: {invoice.patient.full_name_ar or invoice.patient.full_name_en}") c.drawRightString(width - 50, y, ar_cust_name) y -= 20 ar_mrn = reshape_arabic(f"رقم السجل الطبي: {invoice.patient.mrn}") c.drawRightString(width - 50, y, ar_mrn) # Line items y -= 40 c.setFont('Helvetica-Bold', 12) ar_items = reshape_arabic("تفاصيل الفاتورة") c.drawRightString(width - 50, y, ar_items) # Draw table y -= 30 table_y = y for idx, item in enumerate(invoice.line_items.all()): if ARABIC_FONT_AVAILABLE: c.setFont('Arabic', 10) else: c.setFont('Helvetica', 10) c.drawRightString(width - 50, table_y, f"{item.description or 'Service'}") c.drawRightString(width - 250, table_y, f"{item.quantity}") c.drawRightString(width - 350, table_y, f"{item.unit_price:.2f}") c.drawRightString(width - 450, table_y, f"{item.total:.2f} SAR") table_y -= 20 # Totals y = table_y - 30 c.setFont('Helvetica-Bold', 11) ar_subtotal_text = reshape_arabic(f"المجموع الفرعي: {invoice.subtotal:.2f} SAR") c.drawRightString(width - 50, y, ar_subtotal_text) y -= 20 ar_vat_text = reshape_arabic(f"ضريبة القيمة المضافة (15%): {invoice.tax:.2f} SAR") c.drawRightString(width - 50, y, ar_vat_text) y -= 20 c.setFont('Helvetica-Bold', 14) ar_total_text = reshape_arabic(f"المجموع الكلي: {invoice.total:.2f} SAR") c.drawRightString(width - 50, y, ar_total_text) # QR Code if invoice.qr_code: try: qr_buffer = PDFService.generate_qr_code_image(invoice.qr_code) c.drawImage(qr_buffer, 50, 50, width=100, height=100, preserveAspectRatio=True) c.setFont('Helvetica', 8) ar_scan_text = reshape_arabic("امسح للتحقق") c.drawString(50, 35, f"Scan to verify / {ar_scan_text}") except Exception as e: logger.warning(f"Could not add QR code: {e}") # Footer c.setFont('Helvetica', 8) ar_footer_text = reshape_arabic("هذه فاتورة إلكترونية") c.drawCentredString(width/2, 30, f"This is a computer-generated invoice / {ar_footer_text}") # Save PDF c.save() # Get PDF content pdf_content = buffer.getvalue() buffer.close() logger.info(f"Arabic PDF generated for invoice {invoice.invoice_number}") return pdf_content except Exception as e: logger.error(f"Error generating Arabic PDF: {e}") raise