Spaces:
Sleeping
Sleeping
| import io | |
| # streamlit_invoice_generator.py | |
| import os | |
| import zipfile | |
| from typing import Dict, List | |
| import pandas as pd | |
| import streamlit as st | |
| from PIL import Image | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.pdfgen import canvas | |
| from PyPDF2 import PdfReader, PdfWriter | |
| from num2words import num2words # ✅ added for number-to-words | |
| # ------------------------- | |
| # Utility helpers | |
| # ------------------------- | |
| PAGE_W, PAGE_H = A4 | |
| def safe_str(x): | |
| if pd.isna(x): | |
| return "" | |
| return str(x) | |
| def format_amount(x): | |
| try: | |
| num = float(x) | |
| return f"{round(num):,}" # ✅ rounds to nearest whole number with commas | |
| except Exception: | |
| return safe_str(x) | |
| def to_words(value): # ✅ helper for number to words | |
| try: | |
| num = float(value) | |
| if num.is_integer(): | |
| num = int(num) | |
| return num2words(num, to="cardinal", lang="en").title() + " Only" | |
| except Exception: | |
| return "" | |
| # overlay creation: draws text at specified positions | |
| def create_overlay_pdf(invoice: dict, positions: dict, logo_path: str = None) -> io.BytesIO: | |
| packet = io.BytesIO() | |
| c = canvas.Canvas(packet, pagesize=A4) | |
| # draw fields (all keys in positions) | |
| for field, meta in positions.items(): | |
| if field in ["logo", "items_start_y", "items_row_height", "items_columns"]: | |
| continue | |
| if not (isinstance(meta, tuple) and len(meta) == 5): | |
| continue | |
| val = invoice.get(field, "") | |
| x, y, fontname, fontsize, align = meta | |
| c.setFont(fontname, fontsize) | |
| if any(key in field.lower() for key in ["amount", "total"]): | |
| text = format_amount(val) | |
| else: | |
| text = safe_str(val) | |
| if align == "right": | |
| c.drawRightString(x, y, text) | |
| elif align == "center": | |
| c.drawCentredString(x, y, text) | |
| else: | |
| c.drawString(x, y, text) | |
| # draw items table | |
| if "items_start_y" in positions: | |
| y = positions["items_start_y"] | |
| row_height = positions.get("items_row_height", 18) | |
| col_map = positions.get("items_columns", {}) | |
| for idx, it in enumerate(invoice.get("Items", []), start=1): | |
| x = col_map.get("sr") | |
| if x is not None: | |
| c.drawString(x, y, str(idx)) | |
| x = col_map.get("desc") | |
| if x is not None: | |
| c.drawString(x, y, safe_str(it.get("Service Details", ""))) | |
| x = col_map.get("hsn") | |
| if x is not None: | |
| c.drawString(x, y, safe_str(it.get("HSN", ""))) | |
| x = col_map.get("taxable") | |
| if x is not None: | |
| c.drawRightString(x, y, format_amount(it.get("Taxable Value", 0))) | |
| x_perc = col_map.get("cgst_perc") | |
| if x_perc is not None: | |
| c.drawRightString(x_perc, y, safe_str(it.get("CGST %", ""))) | |
| x_amt = col_map.get("cgst") | |
| if x_amt is not None: | |
| c.drawRightString(x_amt, y, format_amount(it.get("CGST Amount", 0))) | |
| x_perc = col_map.get("sgst_perc") | |
| if x_perc is not None: | |
| c.drawRightString(x_perc, y, safe_str(it.get("SGST %", ""))) | |
| x_amt = col_map.get("sgst") | |
| if x_amt is not None: | |
| c.drawRightString(x_amt, y, format_amount(it.get("SGST Amount", 0))) | |
| x_perc = col_map.get("igst_perc") | |
| if x_perc is not None: | |
| c.drawRightString(x_perc, y, safe_str(it.get("IGST %", ""))) | |
| x_amt = col_map.get("igst") | |
| if x_amt is not None: | |
| c.drawRightString(x_amt, y, format_amount(it.get("IGST Amount", 0))) | |
| x = col_map.get("total") | |
| if x is not None: | |
| c.drawRightString(x, y, format_amount(it.get("Total Amount", 0))) | |
| y -= row_height | |
| # ---- Draw TOTAL row ---- | |
| totals = { | |
| "HSN": "", | |
| "Taxable Value": sum(it.get("Taxable Value", 0) for it in invoice.get("Items", [])), | |
| "CGST Amount": sum(it.get("CGST Amount", 0) for it in invoice.get("Items", [])), | |
| "SGST Amount": sum(it.get("SGST Amount", 0) for it in invoice.get("Items", [])), | |
| "IGST Amount": sum(it.get("IGST Amount", 0) for it in invoice.get("Items", [])), | |
| "Total Amount": sum(it.get("Total Amount", 0) for it in invoice.get("Items", [])), | |
| } | |
| y -= row_height # extra gap before TOTAL row | |
| c.setFont("Helvetica", 9) | |
| x = col_map.get("taxable") | |
| if x is not None: | |
| c.drawRightString(x, y - 150, format_amount(totals["Taxable Value"])) | |
| x = col_map.get("cgst") | |
| if x is not None: | |
| c.drawRightString(x, y - 150, format_amount(totals["CGST Amount"])) | |
| x = col_map.get("sgst") | |
| if x is not None: | |
| c.drawRightString(x, y - 150, format_amount(totals["SGST Amount"])) | |
| x = col_map.get("igst") | |
| if x is not None: | |
| c.drawRightString(x, y - 150, format_amount(totals["IGST Amount"])) | |
| x = col_map.get("total") | |
| if x is not None: | |
| c.drawRightString(x, y - 150, format_amount(totals["Total Amount"])) | |
| # ✅ add "In Words" fields | |
| if "Total Invoice Words" in positions: | |
| total_amt = invoice.get("Total Amount", 0) | |
| x, y, fontname, fontsize, align = positions["Total Invoice Words"] | |
| c.setFont(fontname, fontsize) | |
| c.drawString(x, y, to_words(total_amt)) | |
| if "CGST Words" in positions: | |
| cgst_amt = sum(it.get("CGST Amount", 0) for it in invoice.get("Items", [])) | |
| x, y, fontname, fontsize, align = positions["CGST Words"] | |
| c.setFont(fontname, fontsize) | |
| c.drawString(x, y, to_words(cgst_amt)) | |
| if "SGST Words" in positions: | |
| sgst_amt = sum(it.get("SGST Amount", 0) for it in invoice.get("Items", [])) | |
| x, y, fontname, fontsize, align = positions["SGST Words"] | |
| c.setFont(fontname, fontsize) | |
| c.drawString(x, y, to_words(sgst_amt)) | |
| if "IGST Words" in positions: | |
| igst_amt = sum(it.get("IGST Amount", 0) for it in invoice.get("Items", [])) | |
| x, y, fontname, fontsize, align = positions["IGST Words"] | |
| c.setFont(fontname, fontsize) | |
| c.drawString(x, y, to_words(igst_amt)) | |
| # ✅ Always show Rounding Off = 0 | |
| if "Rounding Off" in positions: | |
| x, y, fontname, fontsize, align = positions["Rounding Off"] | |
| c.setFont(fontname, fontsize) | |
| c.drawRightString(x, y, "0") | |
| c.save() | |
| packet.seek(0) | |
| return packet | |
| def merge_overlay_with_template(template_bytes: bytes, overlay_bytes: bytes) -> bytes: | |
| template_reader = PdfReader(io.BytesIO(template_bytes)) | |
| overlay_reader = PdfReader(io.BytesIO(overlay_bytes)) | |
| writer = PdfWriter() | |
| template_page = template_reader.pages[0] | |
| template_page.merge_page(overlay_reader.pages[0]) | |
| writer.add_page(template_page) | |
| out = io.BytesIO() | |
| writer.write(out) | |
| out.seek(0) | |
| return out.read() | |
| # ------------------------- | |
| # Streamlit UI | |
| # ------------------------- | |
| st.set_page_config(page_title="Tax Invoice Generator", layout="wide") | |
| # ====== UI: Styles & Header ====== | |
| st.markdown( | |
| """ | |
| <style> | |
| :root { | |
| --brand: #2457f5; | |
| --brand-2: #0ea5e9; | |
| --text: #0f172a; | |
| --muted: #475569; | |
| --bg-soft: #f8fafc; | |
| --card-border: #e2e8f0; | |
| } | |
| .app-header { | |
| background: linear-gradient(90deg, rgba(36,87,245,.08), rgba(14,165,233,.08)); | |
| border: 1px solid var(--card-border); | |
| border-radius: 12px; | |
| padding: 18px 20px; | |
| margin-bottom: 10px; | |
| } | |
| .app-title { margin: 0; font-size: 28px; color: var(--text); } | |
| .app-sub { margin: 6px 0 0; color: var(--muted); } | |
| .section-title { font-size: 16px; margin: 10px 0 6px; color: var(--text); } | |
| .tip { color: var(--muted); font-size: 14px; } | |
| /* Uploader */ | |
| [data-testid="stFileUploadDropzone"] { | |
| border-radius: 12px; | |
| border: 1px dashed var(--card-border); | |
| background: var(--bg-soft); | |
| } | |
| /* Buttons */ | |
| div.stButton > button { | |
| border-radius: 10px; | |
| border: 1px solid var(--card-border); | |
| background: linear-gradient(180deg, white, #f3f6fb); | |
| color: var(--text); | |
| padding: 10px 14px; | |
| } | |
| div.stButton > button:hover { border-color: #cbd5e1; background: #eef2f8; } | |
| /* Download buttons */ | |
| [data-testid="stDownloadButton"] > button { | |
| border-radius: 10px; padding: 10px 14px; font-weight: 600; | |
| background: linear-gradient(90deg, var(--brand), var(--brand-2)); | |
| border: none; color: white; | |
| } | |
| /* Containers & spacing */ | |
| .block-container { padding-top: 1rem; } | |
| </style> | |
| <div class="app-header"> | |
| <h1 class="app-title">Tax Invoice Generator</h1> | |
| <p class="app-sub">Professional invoices for Inephos & Srestham from your Excel data</p> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| with st.expander("How it works", expanded=True): | |
| st.markdown( | |
| "- Upload a single-sheet Excel file with your invoice data.\n" | |
| "- Choose the company template and click Generate.\n" | |
| "- Download a ZIP with professionally formatted PDFs." | |
| ) | |
| # ✅ Load templates internally | |
| with open("uploads/inephos_template.pdf", "rb") as f: | |
| template_inephos_bytes = f.read() | |
| with open("uploads/srestham_template.pdf", "rb") as f: | |
| template_srestham_bytes = f.read() | |
| # --- Layout: Upload in center, buttons angled outward --- | |
| st.markdown("<div class='section-title'>1) Upload your data</div>", unsafe_allow_html=True) | |
| col1, col2, col3 = st.columns([1,2,1]) | |
| with col2: | |
| data_file = st.file_uploader("Upload customer Excel (.xlsx/.xlsm)", type=["xlsx", "xlsm"]) | |
| st.caption("Tip: Keep column headers simple like 'invoice no', 'taxable value', 'cgst %', etc.") | |
| # Preview uploaded file (non-destructive; resets pointer afterwards) | |
| if data_file is not None: | |
| try: | |
| preview_df = pd.read_excel(data_file, engine="openpyxl") | |
| st.markdown("**Preview**") | |
| st.dataframe(preview_df.head(5), width='stretch') | |
| detected_cols = ", ".join([str(c).strip().lower() for c in preview_df.columns]) | |
| st.caption(f"Detected columns: {detected_cols}") | |
| except Exception as e: | |
| st.warning(f"Could not preview file: {e}") | |
| finally: | |
| try: | |
| data_file.seek(0) | |
| except Exception: | |
| pass | |
| # --- Buttons appear like branches --- | |
| st.markdown("<div class='section-title'>2) Generate invoices</div>", unsafe_allow_html=True) | |
| col1, col2, col3, col4 = st.columns([1.5,1,1,1.5]) | |
| # ------------------------- | |
| # Invoice generation helpers | |
| # ------------------------- | |
| DEFAULT_POSITIONS = { | |
| "Invoice No": (120, 645, "Helvetica", 8, "left"), | |
| "Invoice Date": (368, 645, "Helvetica", 8, "left"), | |
| "Customer Name": (55, 615, "Helvetica", 10, "left"), | |
| "Customer Address": (55, 600, "Helvetica", 9, "left"), | |
| # New fields: | |
| "GSTIN": (362, 621, "Helvetica", 8, "left"), | |
| "PAN": (362, 607, "Helvetica", 8, "left"), | |
| "CIN": (362, 594, "Helvetica", 8, "left"), | |
| "PO No": (362, 582, "Helvetica", 8, "left"), | |
| "State": (362, 569, "Helvetica", 8, "left"), | |
| "items_start_y": 500, | |
| "items_row_height": 30, | |
| "items_columns": { | |
| "sr": 56, | |
| "desc": 75, | |
| "hsn": 225, | |
| "taxable": 298, | |
| "cgst_perc": 311, | |
| "cgst": 345, | |
| "sgst_perc": 357, | |
| "sgst": 396, | |
| "igst_perc": 411, | |
| "igst": 447, | |
| "total": 505 | |
| }, | |
| "Total Amount": (506, 265, "Helvetica", 9, "right"), | |
| "Total Invoice Words": (228, 251, "Helvetica", 9, "left"), | |
| "CGST Words": (228, 236, "Helvetica", 9, "left"), | |
| "SGST Words": (228, 222, "Helvetica", 9, "left"), | |
| "IGST Words": (228, 208, "Helvetica", 9, "left"), | |
| # ✅ Position for Rounding Off | |
| "Rounding Off": (506, 277, "Helvetica", 9, "right"), | |
| } | |
| def build_invoice_dict(r, group=None, inv_no=None): | |
| """Helper to build invoice dict with new fields — always sum Total Amount from items""" | |
| if group is not None: | |
| total_amount = 0 | |
| for _, row in group.iterrows(): | |
| taxable = row.get("taxable value", 0) or 0 | |
| cgst_amt = taxable * (row.get("cgst %", 0) or 0) / 100 | |
| sgst_amt = taxable * (row.get("sgst %", 0) or 0) / 100 | |
| igst_amt = taxable * (row.get("igst %", 0) or 0) / 100 | |
| total_amount += taxable + cgst_amt + sgst_amt + igst_amt | |
| else: | |
| taxable = r.get("taxable value", 0) or 0 | |
| cgst_amt = taxable * (r.get("cgst %", 0) or 0) / 100 | |
| sgst_amt = taxable * (r.get("sgst %", 0) or 0) / 100 | |
| igst_amt = taxable * (r.get("igst %", 0) or 0) / 100 | |
| total_amount = taxable + cgst_amt + sgst_amt + igst_amt | |
| return { | |
| "Invoice No": safe_str(inv_no if inv_no else r.get("invoice no", "")), | |
| "Invoice Date": safe_str(r.get("invoice date","")), | |
| "Customer Name": safe_str(r.get("customer name","")), | |
| "Customer Address": safe_str(r.get("customer address","")), | |
| "GSTIN": safe_str(r.get("gstin","")), | |
| "PAN": safe_str(r.get("pan","")), | |
| "CIN": safe_str(r.get("cin","")), | |
| "PO No": safe_str(r.get("po no","")), | |
| "State": safe_str(r.get("state","")), | |
| "Total Amount": total_amount, | |
| } | |
| def build_items(group_or_row): | |
| """Build item rows; calculate tax amounts from % columns""" | |
| taxable_val = group_or_row.get("taxable value", 0) or 0 | |
| # --- Get tax % --- | |
| cgst_perc = group_or_row.get("cgst %", 0) or 0 | |
| sgst_perc = group_or_row.get("sgst %", 0) or 0 | |
| igst_perc = group_or_row.get("igst %", 0) or 0 | |
| # --- Calculate tax amounts --- | |
| cgst_amt = taxable_val * cgst_perc / 100 | |
| sgst_amt = taxable_val * sgst_perc / 100 | |
| igst_amt = taxable_val * igst_perc / 100 | |
| # --- Calculate total --- | |
| total_amt = taxable_val + cgst_amt + sgst_amt + igst_amt | |
| return { | |
| "Service Details": safe_str(group_or_row.get("service details","")), | |
| "HSN": safe_str(group_or_row.get("hsn","")), | |
| "Taxable Value": taxable_val, | |
| "CGST %": cgst_perc, | |
| "SGST %": sgst_perc, | |
| "IGST %": igst_perc, | |
| "CGST Amount": cgst_amt, | |
| "SGST Amount": sgst_amt, | |
| "IGST Amount": igst_amt, | |
| "Total Amount": total_amt, | |
| } | |
| # ------------------------- | |
| # Invoice generation buttons (angled outward) | |
| # ------------------------- | |
| with col2: | |
| if st.button("🏢 Generate Inephos Invoices"): | |
| if not data_file: | |
| st.error("Please upload the customer Excel.") | |
| else: | |
| with st.spinner("Generating Inephos PDF invoices…"): | |
| df = pd.read_excel(data_file, engine="openpyxl") | |
| df.columns = [str(c).strip().lower() for c in df.columns] | |
| invoices = [] | |
| if "invoice no" in df.columns: | |
| grouped = df.groupby("invoice no") | |
| for inv_no, group in grouped: | |
| first = group.iloc[0] | |
| items = [build_items(r) for _, r in group.iterrows()] | |
| inv = build_invoice_dict(first, group, inv_no) | |
| inv["Items"] = items | |
| invoices.append(inv) | |
| else: | |
| for i, r in df.iterrows(): | |
| inv = build_invoice_dict(r) | |
| inv["Items"] = [build_items(r)] | |
| invoices.append(inv) | |
| zip_buffer = io.BytesIO() | |
| with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for inv in invoices: | |
| overlay = create_overlay_pdf(inv, DEFAULT_POSITIONS, logo_path=None) | |
| final_pdf_bytes = merge_overlay_with_template(template_inephos_bytes, overlay.read()) | |
| filename = f"{inv.get('Invoice No')}_inephos.pdf" | |
| zf.writestr(filename, final_pdf_bytes) | |
| zip_buffer.seek(0) | |
| st.success(f"✅ Generated {len(invoices)} invoices for Inephos.") | |
| st.download_button("📥 Download ZIP — Inephos", data=zip_buffer, file_name="inephos_invoices.zip") | |
| with col3: | |
| if st.button("🏢 Generate Srestham Invoices"): | |
| if not data_file: | |
| st.error("Please upload the customer Excel.") | |
| else: | |
| with st.spinner("Generating Srestham PDF invoices…"): | |
| df = pd.read_excel(data_file, engine="openpyxl") | |
| df.columns = [str(c).strip().lower() for c in df.columns] | |
| invoices = [] | |
| if "invoice no" in df.columns: | |
| grouped = df.groupby("invoice no") | |
| for inv_no, group in grouped: | |
| first = group.iloc[0] | |
| items = [build_items(r) for _, r in group.iterrows()] | |
| inv = build_invoice_dict(first, group, inv_no) | |
| inv["Items"] = items | |
| invoices.append(inv) | |
| else: | |
| for i, r in df.iterrows(): | |
| inv = build_invoice_dict(r) | |
| inv["Items"] = [build_items(r)] | |
| invoices.append(inv) | |
| zip_buffer = io.BytesIO() | |
| with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for inv in invoices: | |
| overlay = create_overlay_pdf(inv, DEFAULT_POSITIONS, logo_path=None) | |
| final_pdf_bytes = merge_overlay_with_template(template_srestham_bytes, overlay.read()) | |
| filename = f"{inv.get('Invoice No')}_srestham.pdf" | |
| zf.writestr(filename, final_pdf_bytes) | |
| zip_buffer.seek(0) | |
| st.success(f"✅ Generated {len(invoices)} invoices for Srestham.") | |
| st.download_button("📥 Download ZIP — Srestham", data=zip_buffer, file_name="srestham_invoices.zip") | |
| # ====== Footer ====== | |
| st.divider() | |
| st.caption("Built for reliable invoice creation.") | |