agdar/finance/pdf_service.py
2025-11-02 14:35:35 +03:00

634 lines
27 KiB
Python

"""
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'<b><font name = "Arabic" size=11>{reshape_arabic(invoice.tenant.name_ar)}</font></b><br/>']
if invoice.tenant.vat_number:
company_parts.append(f'<font size=9>VAT: {invoice.tenant.vat_number}</font><br/>')
if invoice.tenant.address:
company_parts.append(f'<font size=9>{invoice.tenant.address}</font><br/>')
if invoice.tenant.city:
company_parts.append(f'<font size=9>{invoice.tenant.city}</font>')
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'<para align="right">'
f'<font size=24 color="#FFA500"><b>VAT Invoice</b></font><br/><br/>'
f'<font name="Arabic" size=16 color="#FFA500"><b>{ar_vat_invoice}</b></font><br/><br/><br/><br/>'
f'</para>'
)
title_para = Paragraph(title_html, ParagraphStyle('Title', parent=styles['Normal']))
# Invoice details
invoice_details_html = (
f'<para align="right">'
f'<font size=9>Invoice no.</font> - <font name="Arabic" size=9>{ar_invoice_no}</font><br/>'
f'<b><font size=10>{invoice.invoice_number}</font></b><br/><br/>'
f'<font size=9>Invoice date</font> - <font name="Arabic" size=9>{ar_invoice_date}</font><br/>'
f'<b><font size=10>{invoice.issue_date.strftime("%d.%m.%Y")}</font></b><br/><br/>'
f'<font size=9>Due</font> - <font name="Arabic" size=9>{ar_due}</font><br/>'
f'<b><font size=10>{invoice.due_date.strftime("%d.%m.%Y")}</font></b>'
f'</para>'
)
invoice_details_para = Paragraph(invoice_details_html, ParagraphStyle('InvDetails', parent=styles['Normal']))
# Bill to section
bill_to_html = (
f'<para align="right">'
f'<font size=9>Bill to</font> - <font name="Arabic" size=9>{ar_bill_to}</font><br/>'
f'<font name="Arabic" size=10><b>{reshape_arabic(invoice.patient.full_name_ar)}</b></font><br/>'
)
if invoice.patient.email:
bill_to_html += f'<font size=9>{invoice.patient.email}</font><br/>'
if invoice.patient.phone:
bill_to_html += f'<font size=9>{invoice.patient.phone}</font>'
bill_to_html += '</para>'
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'<b>Desc.</b><br/><font name="Arabic" size=7>{ar_description}</font>',
ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_LEFT, textColor=colors.white)),
Paragraph(f'<b>Price</b><br/><font name="Arabic" size=7>{ar_rate}</font>',
ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_RIGHT, textColor=colors.white)),
Paragraph(f'<b>QTY</b><br/><font name="Arabic" size=7>{ar_qty}</font>',
ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER, textColor=colors.white)),
Paragraph(f'<b>VAT</b><br/><font name="Arabic" size=7>{ar_tax}</font>',
ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER, textColor=colors.white)),
Paragraph(f'<b>Discount</b><br/><font name="Arabic" size=7>{ar_disc}</font>',
ParagraphStyle('Hdr', parent=styles['Normal'], fontSize=9, alignment=TA_CENTER, textColor=colors.white)),
Paragraph(f'<b>Total</b><br/><font name="Arabic" size=7>{ar_amount}</font>',
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'<b>Payment instruction</b><br/>'
# f'<font name="Arabic" size=8>{ar_payment_inst}</font><br/><br/>'
]
# 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'<font size=8><b>{payment_method}</b><br/>'
f'Amount: {payment.amount:.2f}<br/>'
f'Date: {payment.payment_date.strftime("%d.%m.%Y")}<br/>'
)
if payment.reference:
payment_inst_parts.append(f'Ref: {payment.reference}<br/>')
payment_inst_parts.append('<br/></font>')
else:
# No payments yet
payment_inst_parts.append(
f'<font size=8>Bank Transfer Details:<br/>'
f'Account: {invoice.tenant.name}<br/>'
f'VAT: {invoice.tenant.vat_number or "N/A"}</font>'
)
payment_inst_html = ''.join(payment_inst_parts)
# Totals (right) - using Saudi Riyal symbol
totals_html_parts = [f'<para align="right"><font size=9>',
f'<b><font size=7>Subtotal</font></b> - <b><font name="Arabic" size=7>{ar_subtotal}</font></b><br/>',
f'<b>{invoice.subtotal:.2f}</b><br/><br/>']
if invoice.discount > 0:
totals_html_parts.append(f'<b><font size=7>Discount</font></b> - <font name="Arabic" size=7>{ar_discount}</font><br/>')
totals_html_parts.append(f'<b>{invoice.discount:.2f}</b><br/><br/>')
# totals_html_parts.append(f'<b>Shipping Cost, <font name="Arabic">{sar_symbol}</font>:</b> 0.00<br/>')
# totals_html_parts.append(f'<font name="Arabic" size=7>{ar_shipping}</font><br/>')
totals_html_parts.append(f'<b><font size=7>VAT</font></b> - <font name="Arabic" size=7>{ar_sales_tax}</font><br/>')
totals_html_parts.append(f'<b>{invoice.tax:.2f}</b><br/><br/>')
totals_html_parts.append(f'<b><font size=7>Total</font></b> - <font name="Arabic" size=7>{ar_total}</font><br/>')
totals_html_parts.append(f'<b>{invoice.total:.2f}</b><br/><br/>')
totals_html_parts.append(f'<b><font size=7 >Amount paid</font></b> - <font name="Arabic" size=7>{ar_amount_paid}</font><br/>')
totals_html_parts.append(f'<b><font color="#1F6427">{invoice.amount_paid:.2f}</font></b><br/><br/>')
totals_html_parts.append(f'<b><font size=7 >Balance Due</font></b> - <font name="Arabic" size=7>{ar_balance_due}</font><br/>')
totals_html_parts.append(f'<b><font color="#D62C20">{invoice.amount_due:.2f}</font></b>')
totals_html_parts.append('</font></para>')
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