""" @helper InvoiceGenerator (Backend/app/core/invoice_generator.py) @purpose Utilities to compile standard Letter-sized PDF invoices and compact 80mm thermal roll receipts on-the-fly using ReportLab. """ from io import BytesIO from reportlab.lib.pagesizes import letter from reportlab.lib import colors from reportlab.lib.units import inch from PIL import Image as PILImage from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image as RLImage from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_RIGHT, TA_LEFT from datetime import datetime import json import os from pathlib import Path def get_invoice_logo_element(max_w=2.0 * inch, max_h=0.8 * inch): """ Finds the latest uploaded invoice branding logo in uploads/invoice_branding/logo/ and returns a ReportLab RLImage element. """ backend_root = Path(__file__).resolve().parents[2] logo_dir = backend_root / "uploads" / "invoice_branding" / "logo" if logo_dir.exists(): # Get active non-variant files sorted by modification time files = [f for f in logo_dir.glob("*.*") if not any(s in f.name for s in ["_thumbnail", "_medium", "_large", "_raw"])] if not files: files = list(logo_dir.glob("*.*")) files = sorted(files, key=os.path.getmtime, reverse=True) for logo_file in files: try: pil_img = PILImage.open(logo_file) bio = BytesIO() if pil_img.mode in ("RGBA", "P"): pil_img = pil_img.convert("RGBA") else: pil_img = pil_img.convert("RGB") pil_img.save(bio, format="PNG") bio.seek(0) w, h = pil_img.size if w <= 0 or h <= 0: continue aspect = h / float(w) render_w = max_w render_h = max_w * aspect if render_h > max_h: render_h = max_h render_w = max_h / aspect return RLImage(bio, width=render_w, height=render_h) except Exception as e: print(f"Error loading logo {logo_file}: {e}") continue return None def get_invoice_branding_data(db=None): company_name = "iFixKart" gstin = "33AAAAA0000A1Z5" address = "Offline Main Store Counter, Chennai | +91 9876543210" gst_rate = 18.0 if db: try: from app.models.SettingModel import Setting setting = db.query(Setting).filter(Setting.setting_key == "invoice_branding").first() if setting and setting.setting_value: val = setting.setting_value if isinstance(val, dict): company_name = val.get("companyName") or company_name gstin = val.get("gstin") or gstin address = val.get("storeAddress") or address if "gstRate" in val and val["gstRate"] is not None: try: gst_rate = float(val["gstRate"]) except (ValueError, TypeError): pass except Exception: pass return { "companyName": company_name, "gstin": gstin, "storeAddress": address, "gstRate": gst_rate } def generate_invoice_pdf(order, customer, order_items, db=None) -> bytes: """ Generate a professional standard Letter-size GST Invoice on-the-fly. """ buffer = BytesIO() doc = SimpleDocTemplate( buffer, pagesize=letter, rightMargin=36, leftMargin=36, topMargin=36, bottomMargin=36 ) styles = getSampleStyleSheet() # Custom styles title_style = ParagraphStyle( 'InvoiceTitle', parent=styles['Heading1'], fontName='Helvetica-Bold', fontSize=22, textColor=colors.HexColor('#1b2559'), spaceAfter=4 ) subtitle_style = ParagraphStyle( 'InvoiceSubtitle', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#e4382f'), spaceAfter=10 ) label_style = ParagraphStyle( 'MetaLabel', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=9, textColor=colors.HexColor('#94a3b8'), spaceAfter=3 ) text_style = ParagraphStyle( 'MetaText', parent=styles['Normal'], fontName='Helvetica', fontSize=9, textColor=colors.HexColor('#1e293b'), spaceAfter=3 ) bold_text_style = ParagraphStyle( 'MetaTextBold', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=9, textColor=colors.HexColor('#1e293b'), spaceAfter=3 ) story = [] # 1. Header (Logo/Title & Metadata) logo_element = get_invoice_logo_element(max_w=2.2 * inch, max_h=0.85 * inch) left_cell = [] if logo_element: left_cell.append(logo_element) else: left_cell.append(Paragraph("GST INVOICE", title_style)) left_cell.append(Paragraph("iFixKart Solutions Platform", subtitle_style)) header_data = [ [ left_cell, Paragraph(f"GST INVOICE
Invoice No: INV-{order.order_no.split('-')[-1]}
Date: {order.created_at.strftime('%d-%b-%Y')}
Status: {order.status}", ParagraphStyle('RightText', parent=text_style, alignment=TA_RIGHT)) ] ] header_table = Table(header_data, colWidths=[3.5 * inch, 4.0 * inch]) header_table.setStyle(TableStyle([ ('VALIGN', (0,0), (-1,-1), 'TOP'), ('BOTTOMPADDING', (0,0), (-1,-1), 0), ('TOPPADDING', (0,0), (-1,-1), 0), ])) story.append(header_table) story.append(Spacer(1, 15)) # 2. Billing & Store Coordinates branding = get_invoice_branding_data(db) seller_html = f"{branding['companyName']}
{branding['storeAddress']}
GSTIN: {branding['gstin']}" # Parse shipping address cust_addr_str = "Customer Billing Details" if order.shipping_address_json: try: addr = json.loads(order.shipping_address_json) cust_addr_str = f"{addr.get('full_name')}
{addr.get('street_address')}
{addr.get('city')}, {addr.get('state')} - {addr.get('pincode')}
Phone: {addr.get('phone')}" except Exception: cust_addr_str = order.shipping_address_json details_data = [ [ Paragraph("SELLER (IFIXKART STORE)", label_style), Paragraph("BILLED TO (CUSTOMER)", label_style) ], [ Paragraph(seller_html, text_style), Paragraph(cust_addr_str, text_style) ] ] details_table = Table(details_data, colWidths=[3.75 * inch, 3.75 * inch]) details_table.setStyle(TableStyle([ ('VALIGN', (0,0), (-1,-1), 'TOP'), ('BACKGROUND', (0,0), (-1,-1), colors.HexColor('#f8fafc')), ('PADDING', (0,0), (-1,-1), 10), ('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), ])) story.append(details_table) story.append(Spacer(1, 20)) # 3. Items Table th_style = ParagraphStyle('TH', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=9, textColor=colors.white) th_right = ParagraphStyle('THR', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=9, textColor=colors.white, alignment=TA_RIGHT) td_style = ParagraphStyle('TD', parent=styles['Normal'], fontName='Helvetica', fontSize=9, textColor=colors.HexColor('#334155')) td_right = ParagraphStyle('TDR', parent=styles['Normal'], fontName='Helvetica', fontSize=9, textColor=colors.HexColor('#334155'), alignment=TA_RIGHT) table_data = [[ Paragraph("S.No", th_style), Paragraph("Item SKU & Name", th_style), Paragraph("Unit Price", th_right), Paragraph("Qty", th_right), Paragraph("Total Price", th_right) ]] for idx, item in enumerate(order_items): table_data.append([ Paragraph(str(idx + 1), td_style), Paragraph(f"{item.sku} - {item.product_name}", td_style), Paragraph(f"Rs. {float(item.unit_price):.2f}", td_right), Paragraph(str(item.quantity), td_right), Paragraph(f"Rs. {float(item.total_price):.2f}", td_right) ]) items_table = Table(table_data, colWidths=[0.5 * inch, 3.8 * inch, 1.1 * inch, 0.6 * inch, 1.5 * inch]) items_table.setStyle(TableStyle([ ('BACKGROUND', (0,0), (-1,0), colors.HexColor('#1b2559')), ('ALIGN', (0,0), (-1,-1), 'LEFT'), ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('BOTTOMPADDING', (0,0), (-1,-1), 8), ('TOPPADDING', (0,0), (-1,-1), 8), ('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#cbd5e1')), ])) story.append(items_table) story.append(Spacer(1, 15)) # 4. Totals & Tax Calculation Breakdown subtotal = float(order.total_amount) tax_amount = float(order.tax_amount) gst_rate = float(branding.get("gstRate", 18.0)) half_rate = round(gst_rate / 2.0, 1) cgst = round(tax_amount / 2, 2) sgst = round(tax_amount / 2, 2) igst = 0.0 totals_data = [ [Paragraph("", text_style), Paragraph("Taxable Value:", label_style), Paragraph(f"Rs. {subtotal:.2f}", td_right)], [Paragraph("", text_style), Paragraph(f"CGST ({half_rate:.1f}%):", label_style), Paragraph(f"Rs. {cgst:.2f}", td_right)], [Paragraph("", text_style), Paragraph(f"SGST ({half_rate:.1f}%):", label_style), Paragraph(f"Rs. {sgst:.2f}", td_right)], [Paragraph("", text_style), Paragraph("IGST (0.0%):", label_style), Paragraph(f"Rs. {igst:.2f}", td_right)], [Paragraph("", text_style), Paragraph("Grand Total:", ParagraphStyle('GrandLabel', parent=label_style, fontSize=11, textColor=colors.HexColor('#1b2559'))), Paragraph(f"Rs. {float(order.final_amount):.2f}", ParagraphStyle('GrandVal', parent=td_right, fontSize=11, fontName='Helvetica-Bold', textColor=colors.HexColor('#1b2559')))] ] totals_table = Table(totals_data, colWidths=[4.2 * inch, 1.8 * inch, 1.5 * inch]) totals_table.setStyle(TableStyle([ ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('LINEBELOW', (1,0), (-1,-2), 0.5, colors.HexColor('#e2e8f0')), ('TOPPADDING', (0,0), (-1,-1), 4), ('BOTTOMPADDING', (0,0), (-1,-1), 4), ])) story.append(totals_table) story.append(Spacer(1, 50)) # 5. Legal Footer footer_text = Paragraph( "This is a computer-generated GST Tax Invoice. No signature is required. Thank you for choosing iFixKart!", ParagraphStyle('Footer', parent=styles['Normal'], fontName='Helvetica-Oblique', fontSize=8, textColor=colors.HexColor('#94a3b8'), alignment=TA_CENTER) ) story.append(footer_text) doc.build(story) pdf_bytes = buffer.getvalue() buffer.close() return pdf_bytes def generate_thermal_invoice_pdf(order, customer, order_items) -> bytes: """ Generate an 80mm thermal roll print receipt (walking invoice format) on-the-fly. Page width is exactly 80mm (approx 226 pt). Page length is dynamic/extended (e.g. 450 pt). """ buffer = BytesIO() # 80mm roll size: 226pt wide, 450pt tall doc = SimpleDocTemplate( buffer, pagesize=(226, 450), rightMargin=10, leftMargin=10, topMargin=15, bottomMargin=15 ) styles = getSampleStyleSheet() title_style = ParagraphStyle( 'ThermalTitle', parent=styles['Heading2'], fontName='Helvetica-Bold', fontSize=12, textColor=colors.black, alignment=TA_CENTER, spaceAfter=2 ) subtitle_style = ParagraphStyle( 'ThermalSubtitle', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=8, textColor=colors.black, alignment=TA_CENTER, spaceAfter=10 ) text_style = ParagraphStyle( 'ThermalText', parent=styles['Normal'], fontName='Helvetica', fontSize=7, textColor=colors.black, spaceAfter=2 ) text_right = ParagraphStyle( 'ThermalTextRight', parent=text_style, alignment=TA_RIGHT ) story = [] # 1. Header thermal_logo = get_invoice_logo_element(max_w=1.8 * inch, max_h=0.6 * inch) if thermal_logo: thermal_logo.hAlign = 'CENTER' story.append(thermal_logo) story.append(Spacer(1, 4)) story.append(Paragraph("iFixKart Retail POS", title_style)) story.append(Paragraph("Solutions Pvt Ltd - Store #1
100 Tech Arcade Main Road, Chennai
GSTIN: 33AAFCI8824J1ZP", subtitle_style)) story.append(Spacer(1, 5)) # 2. Transaction Meta Info story.append(Paragraph(f"Invoice: walk_inv_{order.order_id[:8]}", text_style)) story.append(Paragraph(f"Order No: {order.order_no}", text_style)) story.append(Paragraph(f"Date: {order.created_at.strftime('%d-%b-%Y %H:%M')}", text_style)) payment_method = getattr(order, "payment_method", None) or "COD" story.append(Paragraph(f"Payment: {payment_method} ({order.payment_status})", text_style)) story.append(Spacer(1, 10)) # 3. Item List Header item_header = [ [Paragraph("Item Description", text_style), Paragraph("Qty", text_right), Paragraph("Total", text_right)] ] # 4. Item List Rows for item in order_items: # Truncate long names to save receipt slip width short_name = item.product_name[:24] + '..' if len(item.product_name) > 26 else item.product_name item_header.append([ Paragraph(f"{item.sku}
{short_name}", text_style), Paragraph(str(item.quantity), text_right), Paragraph(f"Rs. {float(item.total_price):.1f}", text_right) ]) # Table Widths: item=120pt, qty=30pt, total=56pt -> Total 206pt items_table = Table(item_header, colWidths=[120, 30, 56]) items_table.setStyle(TableStyle([ ('LINEBELOW', (0,0), (-1,0), 0.5, colors.black), ('LINEBELOW', (0,-1), (-1,-1), 0.5, colors.black), ('PADDING', (0,0), (-1,-1), 3), ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ])) story.append(items_table) story.append(Spacer(1, 8)) # 5. Taxes & Total Breakout tax_amount = float(order.tax_amount) cgst = round(tax_amount / 2, 2) sgst = round(tax_amount / 2, 2) totals_data = [ [Paragraph("Taxable Value:", text_style), Paragraph(f"Rs. {float(order.total_amount):.2f}", text_right)], [Paragraph("CGST (9%):", text_style), Paragraph(f"Rs. {cgst:.2f}", text_right)], [Paragraph("SGST (9%):", text_style), Paragraph(f"Rs. {sgst:.2f}", text_right)], [Paragraph("Grand Total:", ParagraphStyle('GrandLabelThermal', parent=text_style, fontName='Helvetica-Bold', fontSize=9)), Paragraph(f"Rs. {float(order.final_amount):.2f}", ParagraphStyle('GrandValThermal', parent=text_right, fontName='Helvetica-Bold', fontSize=9))] ] totals_table = Table(totals_data, colWidths=[110, 96]) totals_table.setStyle(TableStyle([ ('LINEABOVE', (0,-1), (-1,-1), 0.5, colors.black), ('PADDING', (0,0), (-1,-1), 2), ])) story.append(totals_table) story.append(Spacer(1, 20)) # 6. Thermal Footer story.append(Paragraph("Thank you for your purchase!", ParagraphStyle('F1', parent=text_style, fontName='Helvetica-Bold', alignment=TA_CENTER))) story.append(Paragraph("For support: support@ifixkart.com", ParagraphStyle('F2', parent=text_style, alignment=TA_CENTER))) doc.build(story) pdf_bytes = buffer.getvalue() buffer.close() return pdf_bytes