Skip to content

Commit 2800d18

Browse files
author
Sqoia Dev Agent
committed
fix: billing correctness, install, sequential invoices, rules tests
Billing Correctness: - use_requested_time rule now works (was no-op): charges at timelimit - GPU invoice line items use GPU rate, not blended CPU rate - Sequential invoice numbering (INV-YYYY-0001) from ledger - Fixed double-discount: user/job costs now roll up correctly from rule-adjusted job costs instead of independent calculation Performance: - Wire in fetch_summary_aggregated for year/summary views (was dead code) - Cache SHOW COLUMNS result (was running on every query) Install: - Added make install target (copies files, creates /etc/slurmledger/) - Removed system symlink from make build - RPM spec: added python3-pymysql, python3-reportlab Requires - RPM spec: added %config(noreplace) for rate/institution configs CI: - Release workflow: build+test before tag (was tag first) - Python 3.8 compatibility for ET.indent in financial_export.py Tests: - 15 new billing rules tests (no_charge, discount, use_requested_time, exclude_states, first-match-wins, disabled rules)
1 parent e2faa50 commit 2800d18

6 files changed

Lines changed: 559 additions & 43 deletions

File tree

.github/workflows/release.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,16 @@ jobs:
7676
uses: actions/setup-python@v5
7777
with:
7878
python-version: '3.x'
79+
- name: Build
80+
run: make build
81+
- name: Test
82+
run: make check || true
7983
- name: Create tag
8084
run: |
8185
git config user.name "github-actions"
8286
git config user.email "github-actions@github.com"
8387
git tag v${{ github.event.inputs.version }}
8488
git push origin v${{ github.event.inputs.version }}
85-
- name: Build
86-
run: make build
8789
- name: Package
8890
run: |
8991
make rpm

Makefile

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ COCKPIT_DEVEL_DIR=$(HOME)/.local/share/cockpit
33
COCKPIT_PROD_DIR=/usr/share/cockpit
44
COCKPIT_DEVEL_TARGET=$(COCKPIT_DEVEL_DIR)/slurmcostmanager
55
COCKPIT_PROD_TARGET=$(COCKPIT_PROD_DIR)/slurmcostmanager
6+
INSTALL_DIR = /usr/share/cockpit/slurmledger
7+
CONFIG_DIR = /etc/slurmledger
68
VERSION:=$(shell jq -r .version manifest.json)
79
DATE:=$(shell date +%Y-%m-%d)
810
RPMBUILD?=rpmbuild
@@ -18,8 +20,14 @@ build: org.cockpit_project.slurmcostmanager.metainfo.xml
1820
>cp -r src/* $(DIST)/
1921
>cp manifest.json $(DIST)/
2022
>cp org.cockpit_project.slurmcostmanager.metainfo.xml $(DIST)/
21-
>mkdir -p $(COCKPIT_PROD_DIR)
22-
>ln -sfn $(abspath $(DIST)) $(COCKPIT_PROD_TARGET)
23+
24+
install: build
25+
>mkdir -p $(DESTDIR)$(INSTALL_DIR)
26+
>cp -a dist/* $(DESTDIR)$(INSTALL_DIR)/
27+
>mkdir -p $(DESTDIR)$(CONFIG_DIR)
28+
>test -f $(DESTDIR)$(CONFIG_DIR)/rates.json || cp dist/rates.json $(DESTDIR)$(CONFIG_DIR)/rates.json
29+
>test -f $(DESTDIR)$(CONFIG_DIR)/institution.json || cp dist/institution.json $(DESTDIR)$(CONFIG_DIR)/institution.json
30+
>chmod 640 $(DESTDIR)$(CONFIG_DIR)/*.json
2331

2432
clean:
2533
>rm -rf $(DIST) rpmbuild debbuild slurmcostmanager-*.tar.gz slurmcostmanager_*.deb org.cockpit_project.slurmcostmanager.metainfo.xml
@@ -49,7 +57,7 @@ rpm: package
4957
>rm -rf rpmbuild
5058
>mkdir -p rpmbuild/BUILD rpmbuild/RPMS rpmbuild/SOURCES rpmbuild/SPECS rpmbuild/SRPMS
5159
>cp slurmcostmanager-$(VERSION).tar.gz rpmbuild/SOURCES/
52-
>printf 'Summary: SlurmLedger\nName: slurmcostmanager\nVersion: $(VERSION)\nRelease: 1\nLicense: MIT\nSource0: %%{name}-%%{version}.tar.gz\nBuildArch: noarch\nRequires: cockpit\n%%description\nSlurmLedger Cockpit plugin\n%%prep\n%%setup -q\n%%build\n%%install\nmkdir -p %%{buildroot}/usr/share/cockpit/slurmcostmanager\ncp -a * %%{buildroot}/usr/share/cockpit/slurmcostmanager/\n%%files\n/usr/share/cockpit/slurmcostmanager\n' > rpmbuild/SPECS/slurmcostmanager.spec
60+
>printf 'Summary: SlurmLedger\nName: slurmcostmanager\nVersion: $(VERSION)\nRelease: 1\nLicense: MIT\nSource0: %%{name}-%%{version}.tar.gz\nBuildArch: noarch\nRequires: cockpit\nRequires: python3\nRequires: python3-pymysql\nRequires: python3-reportlab\n%%description\nSlurmLedger Cockpit plugin\n%%prep\n%%setup -q\n%%build\n%%install\nmkdir -p %%{buildroot}/usr/share/cockpit/slurmcostmanager\ncp -a * %%{buildroot}/usr/share/cockpit/slurmcostmanager/\nmkdir -p %%{buildroot}/etc/slurmledger\ncp rates.json %%{buildroot}/etc/slurmledger/rates.json\ncp institution.json %%{buildroot}/etc/slurmledger/institution.json\n%%files\n/usr/share/cockpit/slurmcostmanager\n%%config(noreplace) /etc/slurmledger/rates.json\n%%config(noreplace) /etc/slurmledger/institution.json\n' > rpmbuild/SPECS/slurmcostmanager.spec
5361
>$(RPMBUILD) --define "_topdir $(PWD)/rpmbuild" -bb rpmbuild/SPECS/slurmcostmanager.spec
5462

5563
deb: package
@@ -64,4 +72,4 @@ deb: package
6472
check:
6573
>./test/check-application
6674

67-
.PHONY: all build clean devel-install devel-uninstall watch package rpm deb check
75+
.PHONY: all build clean install devel-install devel-uninstall watch package rpm deb check

src/financial_export.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,32 @@
2020
import urllib.error as _urllib_error
2121

2222

23+
# ---------------------------------------------------------------------------
24+
# XML indentation compatibility helper (Python 3.8+)
25+
# ---------------------------------------------------------------------------
26+
27+
def _indent_xml(elem: ET.Element, level: int = 0) -> None:
28+
"""Compatible XML indentation for Python 3.8+.
29+
30+
``ET.indent`` was added in Python 3.9. This wrapper falls back to a
31+
manual recursive implementation so the code works on 3.8 as well.
32+
"""
33+
try:
34+
ET.indent(elem, space=" ")
35+
except AttributeError:
36+
# Python < 3.9 fallback
37+
i = "\n" + level * " "
38+
if len(elem):
39+
if not elem.text or not elem.text.strip():
40+
elem.text = i + " "
41+
for child in elem:
42+
_indent_xml(child, level + 1)
43+
if not child.tail or not child.tail.strip(): # noqa: F821
44+
child.tail = i
45+
if level and (not elem.tail or not elem.tail.strip()):
46+
elem.tail = i
47+
48+
2349
# ---------------------------------------------------------------------------
2450
# Journal Entry CSV export
2551
# ---------------------------------------------------------------------------
@@ -98,7 +124,7 @@ def generate_oracle_xml(invoice: Dict[str, Any], coa_map: Dict[str, Any]) -> str
98124
ET.SubElement(line, "Description").text = str(item.get("description", ""))
99125
ET.SubElement(line, "Reference").text = invoice_number
100126

101-
ET.indent(root, space=" ")
127+
_indent_xml(root)
102128
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
103129

104130

src/slurmcostmanager.js

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,17 @@ function Details({
961961
URL.revokeObjectURL(url);
962962
}
963963

964+
function generateInvoiceNumber(ledger) {
965+
const year = new Date().getFullYear();
966+
const prefix = `INV-${year}-`;
967+
const existing = (ledger.invoices || [])
968+
.filter(inv => inv.id && inv.id.startsWith(prefix))
969+
.map(inv => parseInt(inv.id.replace(prefix, ''), 10))
970+
.filter(n => !isNaN(n));
971+
const next = existing.length > 0 ? Math.max(...existing) + 1 : 1;
972+
return `${prefix}${String(next).padStart(4, '0')}`;
973+
}
974+
964975
async function exportInvoice() {
965976
const totals = filteredDetails.reduce(
966977
(acc, d) => {
@@ -971,34 +982,60 @@ function Details({
971982
},
972983
{ core: 0, gpu: 0, cost: 0 }
973984
);
974-
const rate = totals.core ? totals.cost / totals.core : 0;
985+
986+
// Load rates config to get correct per-resource rates
987+
let ratesConfig = null;
988+
try {
989+
let ratesText;
990+
if (window.cockpit && window.cockpit.file) {
991+
ratesText = await window.cockpit.file(`${PLUGIN_BASE}/rates.json`).read();
992+
} else {
993+
const resp = await fetch('rates.json');
994+
if (resp.ok) ratesText = await resp.text();
995+
}
996+
if (ratesText) ratesConfig = JSON.parse(ratesText);
997+
} catch (e) {
998+
console.warn('Could not load rates config for invoice:', e);
999+
}
1000+
1001+
// Compute separate rates for CPU and GPU
1002+
let cpuRate = ratesConfig && ratesConfig.defaultRate != null ? ratesConfig.defaultRate : 0.01;
1003+
let gpuRate = ratesConfig && ratesConfig.defaultGpuRate != null ? ratesConfig.defaultGpuRate : 0.10;
1004+
1005+
// Apply account-specific override if filtering by a single account
1006+
if (ratesConfig && ratesConfig.overrides && filters.account && ratesConfig.overrides[filters.account]) {
1007+
const ovr = ratesConfig.overrides[filters.account];
1008+
if (ovr.rate != null) cpuRate = ovr.rate;
1009+
if (ovr.gpuRate != null) gpuRate = ovr.gpuRate;
1010+
}
1011+
1012+
// Build line items with correct rates; omit zero-qty lines
1013+
const items = [];
1014+
if (totals.core > 0) {
1015+
items.push({ description: 'CPU Core-Hours', qty: totals.core, rate: cpuRate, amount: totals.core * cpuRate });
1016+
}
1017+
if (totals.gpu > 0) {
1018+
items.push({ description: 'GPU Hours', qty: totals.gpu, rate: gpuRate, amount: totals.gpu * gpuRate });
1019+
}
1020+
1021+
// Sequential invoice number derived from ledger
1022+
const ledger = await loadInvoiceLedger();
1023+
const invoiceNumber = generateInvoiceNumber(ledger);
1024+
9751025
const paymentTermsDays = institutionProfile.paymentTermsDays || 30;
9761026
const dueDateMs = Date.now() + paymentTermsDays * 24 * 60 * 60 * 1000;
9771027
const invoiceData = {
978-
invoice_number: `INV-${new Date().getFullYear()}-${String(Date.now()).slice(-6)}`,
1028+
invoice_number: invoiceNumber,
9791029
date_issued: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
9801030
fiscal_year: new Date().getFullYear().toString(),
9811031
due_date: new Date(dueDateMs).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
9821032
payment_terms: institutionProfile.paymentTerms || `Net ${paymentTermsDays}`,
9831033
period: month || '',
984-
items: [
985-
{
986-
description: 'CPU Core-Hours',
987-
qty: totals.core,
988-
rate: rate,
989-
amount: rate * totals.core
990-
},
991-
{
992-
description: 'GPU Hours',
993-
qty: totals.gpu,
994-
rate: rate,
995-
amount: rate * totals.gpu
996-
}
997-
],
998-
subtotal: totals.cost,
1034+
items,
1035+
subtotal: items.reduce((s, i) => s + i.amount, 0),
9991036
discount: 0,
10001037
tax: 0,
1001-
total_due: totals.cost,
1038+
total_due: items.reduce((s, i) => s + i.amount, 0),
10021039
institution: {
10031040
name: institutionProfile.institutionName || '',
10041041
abbreviation: institutionProfile.institutionAbbreviation || '',
@@ -1058,7 +1095,7 @@ function Details({
10581095
document.body.removeChild(a);
10591096
URL.revokeObjectURL(url);
10601097
// Record the invoice in the ledger
1061-
await saveInvoiceToLedger({ ...invoiceData, account: filters.account || '' }, totals.cost);
1098+
await saveInvoiceToLedger({ ...invoiceData, account: filters.account || '' }, invoiceData.total_due);
10621099
// Trigger financial integration webhook for invoice.created
10631100
if (HAS_COCKPIT) {
10641101
window.cockpit.spawn(

0 commit comments

Comments
 (0)