Skip to content

Commit ba43f82

Browse files
author
Sqoia Dev Agent
committed
feat: invoice lifecycle, refunds, role-based views, security hardening
Invoice Lifecycle: - Invoice ledger with status tracking (draft → sent → viewed → paid) - Status badges with color coding in Invoices view - Actions: Mark Sent, Mark Paid, Issue Refund, Cancel - Filter invoices by status, account, period Refunds: - Refund modal with amount, reason, partial refund support - Credit memo PDF generation (CREDIT MEMO header, negative amounts) - Refund entries tracked per invoice with audit trail Role-based Views: - Role detection via cockpit.user() + config + sacctmgr - Admin: full access, all accounts, rate editing, invoice management - PI: their accounts, user breakdown, view invoices - Member: personal usage, job history, efficiency tips - Finance: read-only, view all, mark invoices paid - Nav tabs filtered by role, data filtered by role Role-specific Dashboards: - Admin: cluster cost KPI, invoice status summary, top accounts, alerts - PI: account cost, budget remaining, user breakdown - Member: personal cost, recent jobs, efficiency metrics Security: - ReportLab input sanitization (strip HTML/XML tags) - Logo upload 256KB size limit - Pinned CI dependencies - Release workflow gated on passing tests
1 parent 943036e commit ba43f82

6 files changed

Lines changed: 1005 additions & 51 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
with:
1717
python-version: '3.x'
1818
- name: Install Python lint deps
19-
run: pip install flake8
19+
run: pip install flake8==7.1.1
2020
- name: Python lint
2121
run: flake8 src test
2222
- name: Set up Node
@@ -26,7 +26,7 @@ jobs:
2626
- name: Install ESLint
2727
run: |
2828
npm init -y >/dev/null 2>&1
29-
npm install eslint >/dev/null 2>&1
29+
npm install eslint@9.0.0 >/dev/null 2>&1
3030
- name: JavaScript lint
3131
run: npx eslint src test --ext .js
3232

@@ -40,7 +40,7 @@ jobs:
4040
python-version: '3.x'
4141
- name: Python unit tests
4242
run: |
43-
pip install pytest >/dev/null 2>&1
43+
pip install pytest==8.3.3 >/dev/null 2>&1
4444
PYTHONPATH=src python test/unit/slurmdb_validation.test.py
4545
- name: Set up Node
4646
uses: actions/setup-node@v4
@@ -73,6 +73,6 @@ jobs:
7373
with:
7474
python-version: '3.x'
7575
- name: Install Bandit
76-
run: pip install bandit
76+
run: pip install bandit==1.8.0
7777
- name: Run Bandit
7878
run: bandit -r src

.github/workflows/release.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,64 @@ on:
88
required: true
99

1010
jobs:
11+
lint:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- name: Set up Python
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: '3.x'
19+
- name: Install Python lint deps
20+
run: pip install flake8==7.1.1
21+
- name: Python lint
22+
run: flake8 src test
23+
- name: Set up Node
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '18'
27+
- name: Install ESLint
28+
run: |
29+
npm init -y >/dev/null 2>&1
30+
npm install eslint@9.0.0 >/dev/null 2>&1
31+
- name: JavaScript lint
32+
run: npx eslint src test --ext .js
33+
34+
unit-tests:
35+
runs-on: ubuntu-latest
36+
steps:
37+
- uses: actions/checkout@v4
38+
- name: Set up Python
39+
uses: actions/setup-python@v5
40+
with:
41+
python-version: '3.x'
42+
- name: Python unit tests
43+
run: |
44+
pip install pytest==8.3.3 >/dev/null 2>&1
45+
PYTHONPATH=src python test/unit/slurmdb_validation.test.py
46+
- name: Set up Node
47+
uses: actions/setup-node@v4
48+
with:
49+
node-version: '18'
50+
- name: Node unit tests
51+
run: node test/unit/calculator.test.js
52+
53+
security-scan:
54+
runs-on: ubuntu-latest
55+
steps:
56+
- uses: actions/checkout@v4
57+
- name: Set up Python
58+
uses: actions/setup-python@v5
59+
with:
60+
python-version: '3.x'
61+
- name: Install Bandit
62+
run: pip install bandit==1.8.0
63+
- name: Run Bandit
64+
run: bandit -r src
65+
1166
release:
1267
runs-on: ubuntu-latest
68+
needs: [lint, unit-tests, security-scan]
1369
steps:
1470
- uses: actions/checkout@v4
1571
- name: Set up Node

src/invoice.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import io
1414
import json
1515
import os
16+
import re
1617
import sys
1718
import logging
1819
import tempfile
@@ -56,6 +57,14 @@
5657
CONTENT_W = PAGE_W - 2 * MARGIN
5758

5859

60+
# ---------------------------------------------------------------------------
61+
# Input sanitization
62+
# ---------------------------------------------------------------------------
63+
def sanitize_for_paragraph(text):
64+
"""Strip HTML/XML tags to prevent reportlab markup injection."""
65+
return re.sub(r'<[^>]+>', '', str(text))
66+
67+
5968
# ---------------------------------------------------------------------------
6069
# Number formatting helpers
6170
# ---------------------------------------------------------------------------
@@ -195,13 +204,13 @@ def _build_header(invoice_data, styles, logo_path):
195204
except Exception as exc:
196205
logging.warning("Could not embed logo: %s", exc)
197206

198-
left_paras.append(Paragraph("<b>" + (dept or "Your Institution") + "</b>", styles["Normal"]))
207+
left_paras.append(Paragraph("<b>" + sanitize_for_paragraph(dept or "Your Institution") + "</b>", styles["Normal"]))
199208
for line in address_lines:
200209
if line.strip():
201-
left_paras.append(Paragraph(line, styles["Normal"]))
210+
left_paras.append(Paragraph(sanitize_for_paragraph(line), styles["Normal"]))
202211
for line in contact_lines:
203212
if line.strip():
204-
left_paras.append(Paragraph(line, styles["Small"]))
213+
left_paras.append(Paragraph(sanitize_for_paragraph(line), styles["Small"]))
205214

206215
# Right column: INVOICE title + meta
207216
inv_num = invoice_data.get("invoice_number", "")
@@ -210,8 +219,10 @@ def _build_header(invoice_data, styles, logo_path):
210219
period = invoice_data.get("period", "")
211220
payment_terms = invoice_data.get("payment_terms", "")
212221

222+
is_credit_memo = invoice_data.get("is_credit_memo", False)
223+
title_text = "CREDIT MEMO" if is_credit_memo else "INVOICE"
213224
right_paras = [
214-
Paragraph("INVOICE", styles["InvoiceTitle"]),
225+
Paragraph(title_text, styles["InvoiceTitle"]),
215226
Spacer(1, 8),
216227
]
217228

@@ -224,6 +235,8 @@ def _build_header(invoice_data, styles, logo_path):
224235
meta_rows.append(("Period:", period))
225236
if payment_terms:
226237
meta_rows.append(("Terms:", payment_terms))
238+
if invoice_data.get("original_invoice"):
239+
meta_rows.append(("Original Invoice:", str(invoice_data["original_invoice"])))
227240

228241
meta_label_style = ParagraphStyle(
229242
"MetaLabel",
@@ -242,7 +255,7 @@ def _build_header(invoice_data, styles, logo_path):
242255
for label, val in meta_rows:
243256
right_paras.append(
244257
Table(
245-
[[Paragraph(label, meta_label_style), Paragraph(val, meta_val_style)]],
258+
[[Paragraph(label, meta_label_style), Paragraph(sanitize_for_paragraph(val), meta_val_style)]],
246259
colWidths=[0.9 * inch, 1.5 * inch],
247260
style=TableStyle([
248261
("VALIGN", (0, 0), (-1, -1), "TOP"),
@@ -282,13 +295,13 @@ def _build_bill_to(invoice_data, styles):
282295

283296
bill_to_lines = []
284297
name_parts = [contact.get("fullName", ""), contact.get("title", "")]
285-
name_str = ", ".join(p for p in name_parts if p)
298+
name_str = ", ".join(sanitize_for_paragraph(p) for p in name_parts if p)
286299
if name_str:
287300
bill_to_lines.append(name_str)
288301

289302
dept = institution.get("department") or institution.get("name", "")
290303
if dept:
291-
bill_to_lines.append(dept)
304+
bill_to_lines.append(sanitize_for_paragraph(dept))
292305

293306
address_parts = [
294307
institution.get("address", ""),
@@ -297,11 +310,11 @@ def _build_bill_to(invoice_data, styles):
297310
]
298311
for p in address_parts:
299312
if p.strip():
300-
bill_to_lines.append(p)
313+
bill_to_lines.append(sanitize_for_paragraph(p))
301314

302315
email = contact.get("email", "")
303316
if email:
304-
bill_to_lines.append(email)
317+
bill_to_lines.append(sanitize_for_paragraph(email))
305318

306319
content_paras = [Paragraph("<b>BILL TO</b>", styles["SectionHead"])]
307320
for line in bill_to_lines:
@@ -369,7 +382,7 @@ def _build_items_table(invoice_data, styles):
369382
rate_str = _fmt_currency(rate) if isinstance(rate, (int, float)) else str(rate)
370383
amt_str = _fmt_currency(amount) if isinstance(amount, (int, float)) else str(amount)
371384
table_data.append([
372-
Paragraph(item.get("description", ""), cell_style),
385+
Paragraph(sanitize_for_paragraph(item.get("description", "")), cell_style),
373386
Paragraph(qty_str, num_style),
374387
Paragraph(rate_str, num_style),
375388
Paragraph(amt_str, num_style),
@@ -450,11 +463,11 @@ def _build_footer_sections(invoice_data, styles):
450463
left_col = [Paragraph("<b>PAYMENT INFORMATION</b>", styles["SectionHead"])]
451464
for line in bank_info:
452465
if line.strip():
453-
left_col.append(Paragraph(line, styles["Normal"]))
466+
left_col.append(Paragraph(sanitize_for_paragraph(line), styles["Normal"]))
454467

455468
right_col = [Paragraph("<b>NOTES</b>", styles["SectionHead"])]
456469
if notes:
457-
right_col.append(Paragraph(notes, styles["Normal"]))
470+
right_col.append(Paragraph(sanitize_for_paragraph(notes), styles["Normal"]))
458471

459472
foot_table = Table(
460473
[[left_col, right_col]],

src/slurmcostmanager.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,26 @@ nav button:hover {
277277
font-size: 0.8em;
278278
margin-top: 0.2em;
279279
}
280+
281+
/* Modal overlay for refunds and other dialogs */
282+
.modal-overlay {
283+
position: fixed;
284+
inset: 0;
285+
background: rgba(0, 0, 0, 0.5);
286+
display: flex;
287+
align-items: center;
288+
justify-content: center;
289+
z-index: 1000;
290+
}
291+
292+
.modal {
293+
background: #fff;
294+
border-radius: 6px;
295+
padding: 1.5em;
296+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
297+
}
298+
299+
.modal h3 {
300+
margin-top: 0;
301+
margin-bottom: 1em;
302+
}

0 commit comments

Comments
 (0)