|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate a PDF invoice for recharge services. |
| 3 | +
|
| 4 | +Reads invoice information from JSON provided on standard input and writes the |
| 5 | +resulting PDF as a base64 encoded string to standard output. Institution |
| 6 | +profile details are pulled from ``institution.json`` in the same directory to |
| 7 | +populate billing information. |
| 8 | +""" |
| 9 | +import base64 |
| 10 | +import io |
| 11 | +import json |
| 12 | +import os |
| 13 | +import sys |
| 14 | +from datetime import datetime |
| 15 | + |
| 16 | +from reportlab.lib import colors |
| 17 | +from reportlab.lib.enums import TA_RIGHT |
| 18 | +from reportlab.lib.pagesizes import LETTER |
| 19 | +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet |
| 20 | +from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, |
| 21 | + TableStyle) |
| 22 | + |
| 23 | + |
| 24 | +def _load_profile(base_dir): |
| 25 | + """Load institution profile information.""" |
| 26 | + path = os.path.join(base_dir, "institution.json") |
| 27 | + try: |
| 28 | + with open(path, "r", encoding="utf-8") as fh: |
| 29 | + return json.load(fh) |
| 30 | + except Exception: |
| 31 | + return {} |
| 32 | + |
| 33 | + |
| 34 | +def _profile_sections(profile): |
| 35 | + """Construct From and To sections from the profile.""" |
| 36 | + contact = profile.get("primaryContact", {}) |
| 37 | + address_line = profile.get("streetAddress", "") |
| 38 | + city = profile.get("city", "") |
| 39 | + state = profile.get("state", "") |
| 40 | + postal = profile.get("postalCode", "") |
| 41 | + country = profile.get("country", "") |
| 42 | + city_state_postal = f"{city}, {state} {postal}".strip(', ') |
| 43 | + |
| 44 | + from_info = [ |
| 45 | + profile.get("departmentName") or profile.get("institutionName", ""), |
| 46 | + address_line, |
| 47 | + city_state_postal, |
| 48 | + country, |
| 49 | + f"Phone: {contact.get('phone', '')}", |
| 50 | + f"Email: {contact.get('email', '')}" |
| 51 | + ] |
| 52 | + |
| 53 | + to_info = [ |
| 54 | + profile.get("institutionName", ""), |
| 55 | + profile.get("institutionAbbreviation", ""), |
| 56 | + profile.get("campusDivision", ""), |
| 57 | + address_line, |
| 58 | + city_state_postal, |
| 59 | + country, |
| 60 | + f"Primary Contact: {contact.get('fullName', '')}, {contact.get('title', '')}", |
| 61 | + f"Email: {contact.get('email', '')} | Phone: {contact.get('phone', '')}" |
| 62 | + ] |
| 63 | + |
| 64 | + # Remove empty lines |
| 65 | + return ([line for line in from_info if line.strip()], |
| 66 | + [line for line in to_info if line.strip()]) |
| 67 | + |
| 68 | + |
| 69 | +def generate_invoice(buffer, invoice_data): |
| 70 | + """Create the PDF invoice into ``buffer``.""" |
| 71 | + doc = SimpleDocTemplate( |
| 72 | + buffer, |
| 73 | + pagesize=LETTER, |
| 74 | + rightMargin=40, |
| 75 | + leftMargin=40, |
| 76 | + topMargin=40, |
| 77 | + bottomMargin=40, |
| 78 | + ) |
| 79 | + styles = getSampleStyleSheet() |
| 80 | + styles.add(ParagraphStyle(name="RightAlign", parent=styles["Normal"], alignment=TA_RIGHT)) |
| 81 | + styles.add(ParagraphStyle(name="Heading", parent=styles["Heading1"], fontSize=16, spaceAfter=10)) |
| 82 | + styles.add(ParagraphStyle(name="SubHeading", parent=styles["Heading2"], fontSize=12, spaceAfter=8)) |
| 83 | + |
| 84 | + elements = [] |
| 85 | + elements.append(Paragraph("Recharge Services Invoice", styles["Heading"])) |
| 86 | + elements.append(Spacer(1, 12)) |
| 87 | + |
| 88 | + header_table_data = [ |
| 89 | + [ |
| 90 | + f"Invoice Number: {invoice_data['invoice_number']}", |
| 91 | + f"Date Issued: {invoice_data['date_issued']}", |
| 92 | + ], |
| 93 | + [ |
| 94 | + f"Fiscal Year: {invoice_data['fiscal_year']}", |
| 95 | + f"Due Date: {invoice_data['due_date']}", |
| 96 | + ], |
| 97 | + ] |
| 98 | + header_table = Table(header_table_data, colWidths=[250, 250]) |
| 99 | + elements.append(header_table) |
| 100 | + elements.append(Spacer(1, 20)) |
| 101 | + |
| 102 | + elements.append(Paragraph("<b>From (Billed By)</b>", styles["SubHeading"])) |
| 103 | + for line in invoice_data["from_info"]: |
| 104 | + elements.append(Paragraph(line, styles["Normal"])) |
| 105 | + elements.append(Spacer(1, 12)) |
| 106 | + |
| 107 | + elements.append(Paragraph("<b>To (Billed To)</b>", styles["SubHeading"])) |
| 108 | + for line in invoice_data["to_info"]: |
| 109 | + elements.append(Paragraph(line, styles["Normal"])) |
| 110 | + elements.append(Spacer(1, 20)) |
| 111 | + |
| 112 | + elements.append(Paragraph("<b>Invoice Summary</b>", styles["SubHeading"])) |
| 113 | + table_data = [["Description", "Billing Period", "Qty / Units", "Rate", "Amount"]] |
| 114 | + for item in invoice_data["items"]: |
| 115 | + table_data.append( |
| 116 | + [ |
| 117 | + item["description"], |
| 118 | + item["period"], |
| 119 | + item["qty_units"], |
| 120 | + item["rate"], |
| 121 | + item["amount"], |
| 122 | + ] |
| 123 | + ) |
| 124 | + table_data += [ |
| 125 | + ["", "", "", "<b>Subtotal</b>", f"${invoice_data['subtotal']:.2f}"], |
| 126 | + ["", "", "", "<b>Tax</b>", f"${invoice_data['tax']:.2f}"], |
| 127 | + ["", "", "", "<b>Total Due</b>", f"<b>${invoice_data['total_due']:.2f}</b>"], |
| 128 | + ] |
| 129 | + table = Table(table_data, colWidths=[180, 120, 90, 70, 70]) |
| 130 | + table.setStyle( |
| 131 | + TableStyle( |
| 132 | + [ |
| 133 | + ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), |
| 134 | + ("GRID", (0, 0), (-1, -1), 0.25, colors.grey), |
| 135 | + ("ALIGN", (2, 0), (-1, -1), "RIGHT"), |
| 136 | + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), |
| 137 | + ("FONTNAME", (-2, -3), (-1, -1), "Helvetica-Bold"), |
| 138 | + ] |
| 139 | + ) |
| 140 | + ) |
| 141 | + elements.append(table) |
| 142 | + elements.append(Spacer(1, 20)) |
| 143 | + |
| 144 | + elements.append(Paragraph("<b>Payment Instructions</b>", styles["SubHeading"])) |
| 145 | + for line in invoice_data["bank_info"]: |
| 146 | + elements.append(Paragraph(line, styles["Normal"])) |
| 147 | + elements.append(Spacer(1, 20)) |
| 148 | + |
| 149 | + elements.append(Paragraph("<b>Notes</b>", styles["SubHeading"])) |
| 150 | + elements.append(Paragraph(invoice_data["notes"], styles["Normal"])) |
| 151 | + |
| 152 | + elements.append(Spacer(1, 20)) |
| 153 | + elements.append( |
| 154 | + Paragraph( |
| 155 | + "Generated by Recharge Management System on " |
| 156 | + + datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
| 157 | + + ". Based on usage records from Monthly Summary Reports and Detailed Transactions modules.", |
| 158 | + styles["Normal"], |
| 159 | + ) |
| 160 | + ) |
| 161 | + |
| 162 | + doc.build(elements) |
| 163 | + |
| 164 | + |
| 165 | +def main(): |
| 166 | + base_dir = os.path.dirname(os.path.abspath(__file__)) |
| 167 | + profile = _load_profile(base_dir) |
| 168 | + invoice_data = json.load(sys.stdin) |
| 169 | + |
| 170 | + # Fill in profile-based sections if not provided |
| 171 | + from_info, to_info = _profile_sections(profile) |
| 172 | + invoice_data.setdefault("from_info", from_info) |
| 173 | + invoice_data.setdefault("to_info", to_info) |
| 174 | + |
| 175 | + buffer = io.BytesIO() |
| 176 | + generate_invoice(buffer, invoice_data) |
| 177 | + pdf_bytes = buffer.getvalue() |
| 178 | + sys.stdout.write(base64.b64encode(pdf_bytes).decode("ascii")) |
| 179 | + |
| 180 | + |
| 181 | +if __name__ == "__main__": |
| 182 | + main() |
0 commit comments