Skip to content

Commit 3afbc39

Browse files
Merge pull request #121 from NessieCanCode/create-pdf-invoice-generation-script
Add PDF invoice export
2 parents 974f5bd + 2c50860 commit 3afbc39

File tree

2 files changed

+252
-1
lines changed

2 files changed

+252
-1
lines changed

src/invoice.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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()

src/slurmcostmanager.js

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,74 @@ function Details({
855855
URL.revokeObjectURL(url);
856856
}
857857

858+
async function exportInvoice() {
859+
const totals = filteredDetails.reduce(
860+
(acc, d) => {
861+
acc.core += d.core_hours || 0;
862+
acc.gpu += d.gpu_hours || 0;
863+
acc.cost += d.cost || 0;
864+
return acc;
865+
},
866+
{ core: 0, gpu: 0, cost: 0 }
867+
);
868+
const rate = totals.core ? totals.cost / totals.core : 0;
869+
const invoiceData = {
870+
invoice_number: `INV-${Date.now()}`,
871+
date_issued: new Date().toLocaleDateString(),
872+
fiscal_year: new Date().getFullYear().toString(),
873+
due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString(),
874+
items: [
875+
{
876+
description: 'HPC Compute Hours',
877+
period: month || '',
878+
qty_units: `${totals.core.toFixed(2)} CPU Hours`,
879+
rate: `$${rate.toFixed(2)}`,
880+
amount: `$${(rate * totals.core).toFixed(2)}`
881+
},
882+
{
883+
description: 'GPU Usage',
884+
period: month || '',
885+
qty_units: `${totals.gpu.toFixed(2)} GPU Hours`,
886+
rate: `$${rate.toFixed(2)}`,
887+
amount: `$${(rate * totals.gpu).toFixed(2)}`
888+
}
889+
],
890+
subtotal: totals.cost,
891+
tax: 0,
892+
total_due: totals.cost,
893+
bank_info: [
894+
'Bank Name: University Bank',
895+
'Account Number: 123456789',
896+
'Routing Number: 987654321',
897+
`Reference: INV-${Date.now()}`
898+
],
899+
notes:
900+
'Thank you for your prompt payment. For questions regarding this invoice, please contact our office.'
901+
};
902+
try {
903+
const output = await window.cockpit.spawn(
904+
['python3', `${PLUGIN_BASE}/invoice.py`],
905+
{ input: JSON.stringify(invoiceData), err: 'message' }
906+
);
907+
const byteChars = atob(output.trim());
908+
const byteNumbers = new Array(byteChars.length);
909+
for (let i = 0; i < byteChars.length; i++) {
910+
byteNumbers[i] = byteChars.charCodeAt(i);
911+
}
912+
const blob = new Blob([new Uint8Array(byteNumbers)], {
913+
type: 'application/pdf'
914+
});
915+
const url = URL.createObjectURL(blob);
916+
const a = document.createElement('a');
917+
a.href = url;
918+
a.download = 'recharge_invoice.pdf';
919+
a.click();
920+
URL.revokeObjectURL(url);
921+
} catch (e) {
922+
console.error(e);
923+
}
924+
}
925+
858926
const successData = (daily || []).map(d => ({
859927
date: d.date,
860928
success: Math.round(d.core_hours * 0.8),
@@ -894,7 +962,8 @@ function Details({
894962
opts.map(o => React.createElement('option', { key: o, value: o }, o))
895963
);
896964
}),
897-
React.createElement('button', { onClick: exportCSV }, 'Export')
965+
React.createElement('button', { onClick: exportCSV }, 'Export CSV'),
966+
React.createElement('button', { onClick: exportInvoice }, 'Export Invoice')
898967
),
899968
React.createElement(
900969
'div',

0 commit comments

Comments
 (0)