ifixkart-backend/app/core/invoice_generator.py

418 lines
16 KiB
Python

"""
@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"<b>GST INVOICE</b><br/><b>Invoice No:</b> INV-{order.order_no.split('-')[-1]}<br/><b>Date:</b> {order.created_at.strftime('%d-%b-%Y')}<br/><b>Status:</b> {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"<b>{branding['companyName']}</b><br/>{branding['storeAddress']}<br/><b>GSTIN:</b> {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"<b>{addr.get('full_name')}</b><br/>{addr.get('street_address')}<br/>{addr.get('city')}, {addr.get('state')} - {addr.get('pincode')}<br/>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"<b>{item.sku}</b> - {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("<b>Grand Total:</b>", ParagraphStyle('GrandLabel', parent=label_style, fontSize=11, textColor=colors.HexColor('#1b2559'))), Paragraph(f"<b>Rs. {float(order.final_amount):.2f}</b>", 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<br/>100 Tech Arcade Main Road, Chennai<br/>GSTIN: 33AAFCI8824J1ZP", subtitle_style))
story.append(Spacer(1, 5))
# 2. Transaction Meta Info
story.append(Paragraph(f"<b>Invoice:</b> walk_inv_{order.order_id[:8]}", text_style))
story.append(Paragraph(f"<b>Order No:</b> {order.order_no}", text_style))
story.append(Paragraph(f"<b>Date:</b> {order.created_at.strftime('%d-%b-%Y %H:%M')}", text_style))
payment_method = getattr(order, "payment_method", None) or "COD"
story.append(Paragraph(f"<b>Payment:</b> {payment_method} ({order.payment_status})", text_style))
story.append(Spacer(1, 10))
# 3. Item List Header
item_header = [
[Paragraph("<b>Item Description</b>", text_style), Paragraph("<b>Qty</b>", text_right), Paragraph("<b>Total</b>", 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}<br/>{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("<b>Grand Total:</b>", ParagraphStyle('GrandLabelThermal', parent=text_style, fontName='Helvetica-Bold', fontSize=9)), Paragraph(f"<b>Rs. {float(order.final_amount):.2f}</b>", 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