Skip to content

Commit 87b21a3

Browse files
author
Sqoia Dev Agent
committed
feat: SU allocations, financial integration, CI overhaul
Allocation / SU Bank Management: - Allocation schema: prepaid/postpaid, budget SU, periods, carryover, alerts - Allocations tab in Rates editor with full CRUD - Budget tracking per account (used/remaining/percent/alert level) - Budget alerts panel in Admin dashboard - Allocation status in PI dashboard with progress bars Financial Backend Integration: - Journal Entry CSV export (Chart of Accounts mapping) - Oracle Financials Cloud GL Journal Import XML - Generic JSON export - Webhook on invoice events (created/sent/paid/refunded) - Financial Integration config tab with CoA mapping table - Support for Oracle, Workday, Banner, Kuali, Generic Webhook CI/CD: - All test files now run in CI (was only 2 of 9) - ESLint flat config for real JS linting - MySQL integration test with MariaDB service container - pytest.ini for test discovery - Pinned all CI dependency versions
1 parent ba43f82 commit 87b21a3

8 files changed

Lines changed: 1354 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@ jobs:
4141
- name: Python unit tests
4242
run: |
4343
pip install pytest==8.3.3 >/dev/null 2>&1
44-
PYTHONPATH=src python test/unit/slurmdb_validation.test.py
44+
PYTHONPATH=src python -m pytest test/unit/ -v
4545
- name: Set up Node
4646
uses: actions/setup-node@v4
4747
with:
4848
node-version: '18'
49-
- name: Node unit tests
50-
run: node test/unit/calculator.test.js
49+
- name: JS tests
50+
run: |
51+
for f in test/unit/*.test.js; do node "$f"; done
5152
5253
integration-tests:
5354
runs-on: ubuntu-latest
@@ -64,6 +65,33 @@ jobs:
6465
- name: Run integration tests
6566
run: make check
6667

68+
db-integration:
69+
name: Database Integration Test
70+
runs-on: ubuntu-latest
71+
services:
72+
mysql:
73+
image: mariadb:10.11
74+
env:
75+
MYSQL_ROOT_PASSWORD: testpass
76+
MYSQL_DATABASE: slurm_acct_db
77+
ports:
78+
- 3306:3306
79+
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
80+
steps:
81+
- uses: actions/checkout@v4
82+
- uses: actions/setup-python@v5
83+
with:
84+
python-version: "3.11"
85+
- run: pip install pymysql reportlab
86+
- name: Load test schema
87+
run: mysql -h 127.0.0.1 -u root -ptestpass slurm_acct_db < test/example_slurmdb_for_testing.sql
88+
- name: Run integration test
89+
run: |
90+
python3 src/slurmdb.py --host 127.0.0.1 --user root --password testpass \
91+
--database slurm_acct_db --cluster test_cluster \
92+
--start 2024-01-01 --end 2024-12-31 --output - > /dev/null
93+
echo "Integration test passed"
94+
6795
security-scan:
6896
runs-on: ubuntu-latest
6997
steps:

eslint.config.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export default [
2+
{
3+
files: ["src/**/*.js", "test/**/*.js"],
4+
languageOptions: {
5+
ecmaVersion: 2022,
6+
sourceType: "module",
7+
globals: {
8+
window: true, document: true, console: true,
9+
cockpit: true, React: true, ReactDOM: true, Chart: true,
10+
setTimeout: true, clearTimeout: true, fetch: true,
11+
}
12+
},
13+
rules: {
14+
"no-unused-vars": "warn",
15+
"no-undef": "error",
16+
"semi": ["warn", "always"],
17+
}
18+
}
19+
];

pytest.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[pytest]
2+
python_files = *.test.py test_*.py
3+
python_classes = *Tests *Test
4+
python_functions = test_*

src/financial_export.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
#!/usr/bin/env python3
2+
"""Financial backend integration helpers.
3+
4+
Handles export to Journal Entry CSV, Oracle Financials XML, and generic
5+
webhook delivery for invoice events. Invoked by the frontend via
6+
``cockpit.spawn``.
7+
"""
8+
9+
import argparse
10+
import csv
11+
import io
12+
import json
13+
import logging
14+
import sys
15+
import xml.etree.ElementTree as ET
16+
from datetime import datetime, timezone
17+
from typing import Any, Dict, List, Optional
18+
19+
import urllib.request as _urllib_request
20+
import urllib.error as _urllib_error
21+
22+
23+
# ---------------------------------------------------------------------------
24+
# Journal Entry CSV export
25+
# ---------------------------------------------------------------------------
26+
27+
def _coa_for_account(account: str, coa_map: Dict[str, Any]) -> Dict[str, str]:
28+
"""Return the chart-of-accounts entry for *account*, falling back to default."""
29+
entry = coa_map.get(account) or coa_map.get("default") or {}
30+
return {
31+
"fund": str(entry.get("fund", "")),
32+
"org": str(entry.get("org", "")),
33+
"account": str(entry.get("account", "")),
34+
"program": str(entry.get("program", "")),
35+
}
36+
37+
38+
def generate_journal_entry_csv(
39+
invoice: Dict[str, Any],
40+
coa_map: Dict[str, Any],
41+
) -> str:
42+
"""Return a Journal Entry CSV string for *invoice*.
43+
44+
Columns: Fund, Org, Account, Program, Amount, Description, Reference
45+
"""
46+
output = io.StringIO()
47+
writer = csv.writer(output)
48+
writer.writerow(["Fund", "Org", "Account", "Program", "Amount", "Description", "Reference"])
49+
50+
invoice_number = invoice.get("invoice_number", "")
51+
account_name = invoice.get("account", "")
52+
coa = _coa_for_account(account_name, coa_map)
53+
54+
for item in invoice.get("items", []):
55+
amount = item.get("amount", 0)
56+
description = item.get("description", "")
57+
writer.writerow([
58+
coa["fund"],
59+
coa["org"],
60+
coa["account"],
61+
coa["program"],
62+
f"{amount:.2f}",
63+
description,
64+
invoice_number,
65+
])
66+
67+
return output.getvalue()
68+
69+
70+
# ---------------------------------------------------------------------------
71+
# Oracle Financials XML export
72+
# ---------------------------------------------------------------------------
73+
74+
def generate_oracle_xml(invoice: Dict[str, Any], coa_map: Dict[str, Any]) -> str:
75+
"""Return an Oracle GL Journal Import XML string for *invoice*."""
76+
root = ET.Element("GlJournalImport")
77+
header = ET.SubElement(root, "JournalHeader")
78+
79+
invoice_number = invoice.get("invoice_number", "")
80+
ET.SubElement(header, "JournalName").text = invoice_number
81+
ET.SubElement(header, "PeriodName").text = str(invoice.get("period", ""))
82+
ET.SubElement(header, "CurrencyCode").text = "USD"
83+
ET.SubElement(header, "Description").text = f"HPC recharge invoice {invoice_number}"
84+
85+
account_name = invoice.get("account", "")
86+
coa = _coa_for_account(account_name, coa_map)
87+
88+
lines = ET.SubElement(header, "JournalLines")
89+
for idx, item in enumerate(invoice.get("items", []), start=1):
90+
amount = item.get("amount", 0)
91+
line = ET.SubElement(lines, "JournalLine")
92+
ET.SubElement(line, "LineNumber").text = str(idx)
93+
ET.SubElement(line, "Fund").text = coa["fund"]
94+
ET.SubElement(line, "Org").text = coa["org"]
95+
ET.SubElement(line, "Account").text = coa["account"]
96+
ET.SubElement(line, "Program").text = coa["program"]
97+
ET.SubElement(line, "EnteredAmount").text = f"{amount:.2f}"
98+
ET.SubElement(line, "Description").text = str(item.get("description", ""))
99+
ET.SubElement(line, "Reference").text = invoice_number
100+
101+
ET.indent(root, space=" ")
102+
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
103+
104+
105+
# ---------------------------------------------------------------------------
106+
# Webhook delivery
107+
# ---------------------------------------------------------------------------
108+
109+
def send_webhook(
110+
event_type: str,
111+
invoice_data: Dict[str, Any],
112+
webhook_url: str,
113+
api_key: str = "",
114+
) -> None:
115+
"""POST an invoice event to an external financial system.
116+
117+
Raises :class:`RuntimeError` on HTTP errors so the caller can surface
118+
feedback to the user.
119+
"""
120+
if not webhook_url:
121+
raise ValueError("webhookUrl is not configured")
122+
123+
payload = {
124+
"event": event_type,
125+
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
126+
"invoice": invoice_data,
127+
}
128+
body = json.dumps(payload).encode()
129+
130+
req = _urllib_request.Request(
131+
webhook_url,
132+
data=body,
133+
method="POST",
134+
)
135+
req.add_header("Content-Type", "application/json")
136+
if api_key:
137+
req.add_header("Authorization", f"Bearer {api_key}")
138+
139+
try:
140+
with _urllib_request.urlopen(req, timeout=15) as resp:
141+
status = resp.status
142+
except _urllib_error.HTTPError as exc:
143+
raise RuntimeError(f"Webhook delivery failed: HTTP {exc.code} {exc.reason}") from exc
144+
except _urllib_error.URLError as exc:
145+
raise RuntimeError(f"Webhook delivery failed: {exc.reason}") from exc
146+
147+
if status not in range(200, 300):
148+
raise RuntimeError(f"Webhook delivery failed: HTTP {status}")
149+
150+
151+
# ---------------------------------------------------------------------------
152+
# Generic JSON export
153+
# ---------------------------------------------------------------------------
154+
155+
def generate_json_export(invoice: Dict[str, Any]) -> str:
156+
"""Return a pretty-printed JSON export of the invoice."""
157+
return json.dumps(invoice, indent=2, default=str)
158+
159+
160+
# ---------------------------------------------------------------------------
161+
# CLI entry point (called by cockpit.spawn from the JS frontend)
162+
# ---------------------------------------------------------------------------
163+
164+
def _load_institution_config(plugin_base: Optional[str] = None) -> Dict[str, Any]:
165+
import os
166+
path = os.path.join(plugin_base or os.path.dirname(__file__), "institution.json")
167+
try:
168+
with open(path) as fh:
169+
return json.load(fh)
170+
except (OSError, json.JSONDecodeError) as exc:
171+
logging.warning("Could not load institution.json: %s", exc)
172+
return {}
173+
174+
175+
if __name__ == "__main__":
176+
logging.basicConfig(level=logging.WARNING)
177+
178+
parser = argparse.ArgumentParser(
179+
description="Financial export helper for SlurmLedger"
180+
)
181+
parser.add_argument(
182+
"--event",
183+
required=True,
184+
help="Event type: invoice.created, invoice.sent, invoice.paid, invoice.refunded",
185+
)
186+
parser.add_argument(
187+
"--invoice-id",
188+
dest="invoice_id",
189+
required=True,
190+
help="Invoice ID to look up in the ledger",
191+
)
192+
parser.add_argument(
193+
"--format",
194+
default="webhook",
195+
choices=["webhook", "journal_csv", "oracle_xml", "json"],
196+
help="Export format (default: webhook)",
197+
)
198+
parser.add_argument(
199+
"--ledger",
200+
default="/etc/slurmledger/invoices.json",
201+
help="Path to invoices.json ledger",
202+
)
203+
parser.add_argument(
204+
"--plugin-base",
205+
dest="plugin_base",
206+
default=None,
207+
help="Plugin installation directory (for institution.json)",
208+
)
209+
args = parser.parse_args()
210+
211+
try:
212+
# Load the invoice from the ledger
213+
try:
214+
with open(args.ledger) as fh:
215+
ledger = json.load(fh)
216+
except (OSError, json.JSONDecodeError) as exc:
217+
print(json.dumps({"error": type(exc).__name__, "message": str(exc)}))
218+
sys.exit(1)
219+
220+
invoices: List[Dict[str, Any]] = ledger.get("invoices", [])
221+
invoice = next((inv for inv in invoices if inv.get("id") == args.invoice_id), None)
222+
if invoice is None:
223+
msg = f"Invoice {args.invoice_id} not found"
224+
print(json.dumps({"error": "NotFound", "message": msg}))
225+
sys.exit(1)
226+
227+
institution = _load_institution_config(args.plugin_base)
228+
fi_cfg: Dict[str, Any] = institution.get("financialIntegration", {})
229+
coa_map: Dict[str, Any] = fi_cfg.get("chartOfAccounts", {})
230+
231+
if args.format == "journal_csv":
232+
print(generate_journal_entry_csv(invoice, coa_map))
233+
elif args.format == "oracle_xml":
234+
print(generate_oracle_xml(invoice, coa_map))
235+
elif args.format == "json":
236+
print(generate_json_export(invoice))
237+
else:
238+
# webhook
239+
if not fi_cfg.get("enabled"):
240+
print(json.dumps({
241+
"status": "skipped",
242+
"reason": "Financial integration is disabled",
243+
}))
244+
sys.exit(0)
245+
webhook_url = fi_cfg.get("webhookUrl", "")
246+
api_key = fi_cfg.get("apiKey", "")
247+
send_webhook(args.event, invoice, webhook_url, api_key)
248+
print(json.dumps({"status": "ok", "event": args.event, "invoice_id": args.invoice_id}))
249+
250+
except Exception as exc: # noqa: BLE001
251+
print(json.dumps({"error": type(exc).__name__, "message": str(exc)}))
252+
sys.exit(1)

src/institution.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
"departmentName": "",
2525
"costCenter": "",
2626
"notes": "",
27-
"logo": ""
27+
"logo": "",
28+
"financialIntegration": {
29+
"enabled": false,
30+
"type": "none",
31+
"webhookUrl": "",
32+
"apiKey": "",
33+
"chartOfAccounts": {}
34+
}
2835
}
2936

src/rates.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,26 @@
2020
"gpuRate": 0.25,
2121
"discount": 0.1
2222
}
23+
},
24+
"allocations": {
25+
"physics-lab": {
26+
"type": "prepaid",
27+
"budget_su": 500000,
28+
"period": "annual",
29+
"start_date": "2026-01-01",
30+
"end_date": "2026-12-31",
31+
"carryover": false,
32+
"alerts": [80, 90, 100]
33+
},
34+
"chem-dept": {
35+
"type": "postpaid",
36+
"billing_period": "monthly",
37+
"budget_su": null
38+
}
39+
},
40+
"billing_defaults": {
41+
"type": "postpaid",
42+
"billing_period": "monthly",
43+
"payment_terms_days": 30
2344
}
2445
}

0 commit comments

Comments
 (0)