diff --git a/src/invoice.py b/src/invoice.py new file mode 100755 index 0000000..6110b62 --- /dev/null +++ b/src/invoice.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Generate a PDF invoice for recharge services. + +Reads invoice information from JSON provided on standard input and writes the +resulting PDF as a base64 encoded string to standard output. Institution +profile details are pulled from ``institution.json`` in the same directory to +populate billing information. +""" +import base64 +import io +import json +import os +import sys +from datetime import datetime + +from reportlab.lib import colors +from reportlab.lib.enums import TA_RIGHT +from reportlab.lib.pagesizes import LETTER +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, + TableStyle) + + +def _load_profile(base_dir): + """Load institution profile information.""" + path = os.path.join(base_dir, "institution.json") + try: + with open(path, "r", encoding="utf-8") as fh: + return json.load(fh) + except Exception: + return {} + + +def _profile_sections(profile): + """Construct From and To sections from the profile.""" + contact = profile.get("primaryContact", {}) + address_line = profile.get("streetAddress", "") + city = profile.get("city", "") + state = profile.get("state", "") + postal = profile.get("postalCode", "") + country = profile.get("country", "") + city_state_postal = f"{city}, {state} {postal}".strip(', ') + + from_info = [ + profile.get("departmentName") or profile.get("institutionName", ""), + address_line, + city_state_postal, + country, + f"Phone: {contact.get('phone', '')}", + f"Email: {contact.get('email', '')}" + ] + + to_info = [ + profile.get("institutionName", ""), + profile.get("institutionAbbreviation", ""), + profile.get("campusDivision", ""), + address_line, + city_state_postal, + country, + f"Primary Contact: {contact.get('fullName', '')}, {contact.get('title', '')}", + f"Email: {contact.get('email', '')} | Phone: {contact.get('phone', '')}" + ] + + # Remove empty lines + return ([line for line in from_info if line.strip()], + [line for line in to_info if line.strip()]) + + +def generate_invoice(buffer, invoice_data): + """Create the PDF invoice into ``buffer``.""" + doc = SimpleDocTemplate( + buffer, + pagesize=LETTER, + rightMargin=40, + leftMargin=40, + topMargin=40, + bottomMargin=40, + ) + styles = getSampleStyleSheet() + styles.add(ParagraphStyle(name="RightAlign", parent=styles["Normal"], alignment=TA_RIGHT)) + styles.add(ParagraphStyle(name="Heading", parent=styles["Heading1"], fontSize=16, spaceAfter=10)) + styles.add(ParagraphStyle(name="SubHeading", parent=styles["Heading2"], fontSize=12, spaceAfter=8)) + + elements = [] + elements.append(Paragraph("Recharge Services Invoice", styles["Heading"])) + elements.append(Spacer(1, 12)) + + header_table_data = [ + [ + f"Invoice Number: {invoice_data['invoice_number']}", + f"Date Issued: {invoice_data['date_issued']}", + ], + [ + f"Fiscal Year: {invoice_data['fiscal_year']}", + f"Due Date: {invoice_data['due_date']}", + ], + ] + header_table = Table(header_table_data, colWidths=[250, 250]) + elements.append(header_table) + elements.append(Spacer(1, 20)) + + elements.append(Paragraph("From (Billed By)", styles["SubHeading"])) + for line in invoice_data["from_info"]: + elements.append(Paragraph(line, styles["Normal"])) + elements.append(Spacer(1, 12)) + + elements.append(Paragraph("To (Billed To)", styles["SubHeading"])) + for line in invoice_data["to_info"]: + elements.append(Paragraph(line, styles["Normal"])) + elements.append(Spacer(1, 20)) + + elements.append(Paragraph("Invoice Summary", styles["SubHeading"])) + table_data = [["Description", "Billing Period", "Qty / Units", "Rate", "Amount"]] + for item in invoice_data["items"]: + table_data.append( + [ + item["description"], + item["period"], + item["qty_units"], + item["rate"], + item["amount"], + ] + ) + table_data += [ + ["", "", "", "Subtotal", f"${invoice_data['subtotal']:.2f}"], + ["", "", "", "Tax", f"${invoice_data['tax']:.2f}"], + ["", "", "", "Total Due", f"${invoice_data['total_due']:.2f}"], + ] + table = Table(table_data, colWidths=[180, 120, 90, 70, 70]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), + ("GRID", (0, 0), (-1, -1), 0.25, colors.grey), + ("ALIGN", (2, 0), (-1, -1), "RIGHT"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTNAME", (-2, -3), (-1, -1), "Helvetica-Bold"), + ] + ) + ) + elements.append(table) + elements.append(Spacer(1, 20)) + + elements.append(Paragraph("Payment Instructions", styles["SubHeading"])) + for line in invoice_data["bank_info"]: + elements.append(Paragraph(line, styles["Normal"])) + elements.append(Spacer(1, 20)) + + elements.append(Paragraph("Notes", styles["SubHeading"])) + elements.append(Paragraph(invoice_data["notes"], styles["Normal"])) + + elements.append(Spacer(1, 20)) + elements.append( + Paragraph( + "Generated by Recharge Management System on " + + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + ". Based on usage records from Monthly Summary Reports and Detailed Transactions modules.", + styles["Normal"], + ) + ) + + doc.build(elements) + + +def main(): + base_dir = os.path.dirname(os.path.abspath(__file__)) + profile = _load_profile(base_dir) + invoice_data = json.load(sys.stdin) + + # Fill in profile-based sections if not provided + from_info, to_info = _profile_sections(profile) + invoice_data.setdefault("from_info", from_info) + invoice_data.setdefault("to_info", to_info) + + buffer = io.BytesIO() + generate_invoice(buffer, invoice_data) + pdf_bytes = buffer.getvalue() + sys.stdout.write(base64.b64encode(pdf_bytes).decode("ascii")) + + +if __name__ == "__main__": + main() diff --git a/src/slurmcostmanager.js b/src/slurmcostmanager.js index 186e387..092a80d 100644 --- a/src/slurmcostmanager.js +++ b/src/slurmcostmanager.js @@ -855,6 +855,74 @@ function Details({ URL.revokeObjectURL(url); } + async function exportInvoice() { + const totals = filteredDetails.reduce( + (acc, d) => { + acc.core += d.core_hours || 0; + acc.gpu += d.gpu_hours || 0; + acc.cost += d.cost || 0; + return acc; + }, + { core: 0, gpu: 0, cost: 0 } + ); + const rate = totals.core ? totals.cost / totals.core : 0; + const invoiceData = { + invoice_number: `INV-${Date.now()}`, + date_issued: new Date().toLocaleDateString(), + fiscal_year: new Date().getFullYear().toString(), + due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString(), + items: [ + { + description: 'HPC Compute Hours', + period: month || '', + qty_units: `${totals.core.toFixed(2)} CPU Hours`, + rate: `$${rate.toFixed(2)}`, + amount: `$${(rate * totals.core).toFixed(2)}` + }, + { + description: 'GPU Usage', + period: month || '', + qty_units: `${totals.gpu.toFixed(2)} GPU Hours`, + rate: `$${rate.toFixed(2)}`, + amount: `$${(rate * totals.gpu).toFixed(2)}` + } + ], + subtotal: totals.cost, + tax: 0, + total_due: totals.cost, + bank_info: [ + 'Bank Name: University Bank', + 'Account Number: 123456789', + 'Routing Number: 987654321', + `Reference: INV-${Date.now()}` + ], + notes: + 'Thank you for your prompt payment. For questions regarding this invoice, please contact our office.' + }; + try { + const output = await window.cockpit.spawn( + ['python3', `${PLUGIN_BASE}/invoice.py`], + { input: JSON.stringify(invoiceData), err: 'message' } + ); + const byteChars = atob(output.trim()); + const byteNumbers = new Array(byteChars.length); + for (let i = 0; i < byteChars.length; i++) { + byteNumbers[i] = byteChars.charCodeAt(i); + } + const blob = new Blob([new Uint8Array(byteNumbers)], { + type: 'application/pdf' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'recharge_invoice.pdf'; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error(e); + } + } + const successData = (daily || []).map(d => ({ date: d.date, success: Math.round(d.core_hours * 0.8), @@ -894,7 +962,8 @@ function Details({ opts.map(o => React.createElement('option', { key: o, value: o }, o)) ); }), - React.createElement('button', { onClick: exportCSV }, 'Export') + React.createElement('button', { onClick: exportCSV }, 'Export CSV'), + React.createElement('button', { onClick: exportInvoice }, 'Export Invoice') ), React.createElement( 'div',