Files
2026-08-21 01:55:23 +00:00

257 lines
11 KiB
Python

"""Per-resource cost breakdown (INR rate card) and tenant report rendering.
Costs use the ConnectVM rate card (pricing.py): flavor monthly prices
prorated by billable hours in the period. The CloudKitty total is kept
alongside as the platform-metered reference.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
import pricing
def parse_ts(value: str | None) -> datetime | None:
if not value:
return None
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
# OpenStack API timestamps are UTC but may omit the suffix
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt
def hours_in_period(start: datetime | None, begin: datetime, end: datetime,
now: datetime) -> float:
start = max(start, begin) if start else begin
stop = min(now, end)
return max((stop - start).total_seconds() / 3600, 0.0)
def _prorate(monthly: float, hrs: float, total_hrs: float) -> float:
return monthly * (hrs / total_hrs) if total_hrs else 0.0
def vm_rows(servers: list[dict], begin, end, now) -> list[dict]:
total_hrs = pricing.period_hours(begin, end)
rows = []
for s in servers:
fl = s.get("flavor") or {}
vcpus = fl.get("vcpus", 0) or 0
ram_mb = fl.get("ram", 0) or 0
disk_gb = (fl.get("disk", 0) or 0) + (fl.get("ephemeral", 0) or 0)
started = parse_ts(s.get("OS-SRV-USG:launched_at")) or parse_ts(s.get("created"))
hrs = hours_in_period(started, begin, end, now)
age_days = max((now - started).total_seconds() / 86400, 0) if started else 0
monthly, source, mismatch = pricing.flavor_monthly(
fl.get("original_name", ""), vcpus, ram_mb, disk_gb
)
status = s.get("status", "?")
flags = []
if status == "SHUTOFF":
flags.append("STOPPED — still billing at full rate")
elif status == "ERROR":
flags.append("ERROR state — quota still allocated, still billing; delete to stop")
if mismatch:
flags.append("flavor spec differs from rate card — price needs review")
elif source == "computed":
flags.append("custom flavor — priced from add-on rates")
rows.append({
"name": s.get("name", "?"),
"id": s.get("id"),
"status": status,
"flavor": fl.get("original_name", "?"),
"vcpus": vcpus,
"ram_mb": ram_mb,
"disk_gb": disk_gb,
"started": started.isoformat() if started else None,
"age_days": round(age_days, 1),
"hours_in_period": round(hrs, 1),
"monthly_rate": round(monthly, 2),
"price_source": source,
"est_cost": round(_prorate(monthly, hrs, total_hrs), 2),
"flags": flags,
})
rows.sort(key=lambda r: r["est_cost"], reverse=True)
return rows
def volume_rows(volumes: list[dict], begin, end, now) -> list[dict]:
total_hrs = pricing.period_hours(begin, end)
rows = []
for v in volumes:
size = v.get("size", 0) or 0
created = parse_ts(v.get("created_at"))
hrs = hours_in_period(created, begin, end, now)
age_days = max((now - created).total_seconds() / 86400, 0) if created else 0
monthly = size * pricing.ADDON_DISK_PER_GB
attached = bool(v.get("attachments"))
flags = [] if attached else ["DETACHED — still billing"]
rows.append({
"name": v.get("name") or "(unnamed)",
"id": v.get("id"),
"status": v.get("status", "?"),
"size_gb": size,
"attached": attached,
"created": created.isoformat() if created else None,
"age_days": round(age_days, 1),
"hours_in_period": round(hrs, 1),
"monthly_rate": round(monthly, 2),
"est_cost": round(_prorate(monthly, hrs, total_hrs), 2),
"flags": flags,
})
rows.sort(key=lambda r: r["est_cost"], reverse=True)
return rows
def fip_rows(fips: list[dict], begin, end, now) -> list[dict]:
total_hrs = pricing.period_hours(begin, end)
rows = []
for f in fips:
created = parse_ts(f.get("created_at"))
hrs = hours_in_period(created, begin, end, now)
age_days = max((now - created).total_seconds() / 86400, 0) if created else 0
mapped = bool(f.get("port_id"))
flags = [] if mapped else ["UNASSIGNED — reserved, still billing"]
rows.append({
"address": f.get("floating_ip_address", "?"),
"id": f.get("id"),
"status": f.get("status", "?"),
"mapped": mapped,
"created": created.isoformat() if created else None,
"age_days": round(age_days, 1),
"hours_in_period": round(hrs, 1),
"monthly_rate": pricing.FIP_MONTHLY,
"est_cost": round(_prorate(pricing.FIP_MONTHLY, hrs, total_hrs), 2),
"flags": flags,
})
rows.sort(key=lambda r: r["est_cost"], reverse=True)
return rows
def build_report(tenant: str, begin, end, ck_total: float,
vms: list[dict], vols: list[dict], fips: list[dict]) -> dict:
all_rows = vms + vols + fips
accrued = sum(r["est_cost"] for r in all_rows)
run_rate = sum(r["monthly_rate"] for r in all_rows)
total_hrs = pricing.period_hours(begin, end)
now = datetime.now(begin.tzinfo)
remaining_hrs = max((end - now).total_seconds() / 3600, 0)
projected = accrued + run_rate * (remaining_hrs / total_hrs)
return {
"tenant": tenant,
"period": {"begin": begin.isoformat(), "end": end.isoformat()},
"generated_at": now.isoformat(),
"currency": "INR",
"bill_to_date": round(accrued, 2),
"run_rate_per_month": round(run_rate, 2),
"projected_month_end": round(projected, 2),
"cloudkitty_metered_total": round(ck_total, 4),
"counts": {
"vms": len(vms),
"volumes": len(vols),
"floating_ips": len(fips),
"stopped_vms_still_billing": sum(1 for r in vms if r["status"] == "SHUTOFF"),
"error_vms_still_billing": sum(1 for r in vms if r["status"] == "ERROR"),
"detached_volumes": sum(1 for r in vols if not r["attached"]),
"unassigned_fips": sum(1 for r in fips if not r["mapped"]),
},
"vms": vms,
"volumes": vols,
"floating_ips": fips,
"notes": [
"Billing is allocation-based: a STOPPED (SHUTOFF) or ERROR VM is "
"billed in full until shelved-offloaded or deleted.",
"Detached volumes and unassigned floating IPs keep billing.",
"VMs are charged their flavor's monthly price prorated by "
"billable hours; custom flavors use add-on rates "
"(₹480/vCPU, ₹192/GB RAM, ₹96/10 GB SSD per month).",
"Floating IPs: ₹300/month each. Snapshots, load balancers, "
"backup storage and bandwidth overage are not yet metered.",
],
}
def _table(headers: list[str], rows: list[list]) -> str:
head = "".join(f"<th>{h}</th>" for h in headers)
body = ""
for r in rows:
cells = "".join(f"<td>{c}</td>" for c in r)
body += f"<tr>{cells}</tr>"
if not body:
body = f"<tr><td colspan='{len(headers)}' class='empty'>none</td></tr>"
return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
def render_html(report: dict) -> str:
p = report["period"]
flags = lambda r: (
f"<br><span class='flag'>{' '.join(r['flags'])}</span>" if r["flags"] else ""
)
vms = _table(
["VM", "Status", "Flavor", "vCPU", "RAM", "Disk", "Age (days)",
"Billable hrs", "Rate/mo", "Cost to date"],
[[
v["name"], v["status"] + flags(v), v["flavor"], v["vcpus"],
f"{v['ram_mb'] // 1024} GB", f"{v['disk_gb']} GB", v["age_days"],
v["hours_in_period"], pricing.inr(v["monthly_rate"]),
pricing.inr(v["est_cost"]),
] for v in report["vms"]],
)
vols = _table(
["Volume", "Status", "Size", "Attached", "Age (days)", "Billable hrs",
"Rate/mo", "Cost to date"],
[[
v["name"], v["status"] + flags(v), f"{v['size_gb']} GB",
"yes" if v["attached"] else "no", v["age_days"], v["hours_in_period"],
pricing.inr(v["monthly_rate"]), pricing.inr(v["est_cost"]),
] for v in report["volumes"]],
)
fips = _table(
["Floating IP", "Status", "Mapped", "Age (days)", "Billable hrs",
"Rate/mo", "Cost to date"],
[[
f["address"], f["status"] + flags(f),
"yes" if f["mapped"] else "no", f["age_days"], f["hours_in_period"],
pricing.inr(f["monthly_rate"]), pricing.inr(f["est_cost"]),
] for f in report["floating_ips"]],
)
notes = "".join(f"<li>{n}</li>" for n in report["notes"])
c = report["counts"]
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Billing report — {report['tenant']}</title>
<style>
body{{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;max-width:1080px;margin:32px auto;padding:0 20px;color:#1a1a2e}}
h1{{font-size:22px}} h2{{font-size:16px;margin-top:32px;border-bottom:2px solid #e5e7f0;padding-bottom:6px}}
.meta{{color:#666;font-size:13px}}
.total{{background:#f0f4ff;border:1px solid #d0d9f5;border-radius:10px;padding:16px 20px;margin:20px 0;font-size:15px}}
.total b{{font-size:24px}}
table{{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px}}
th{{text-align:left;background:#f7f8fc;padding:8px;border-bottom:2px solid #e0e3f0}}
td{{padding:8px;border-bottom:1px solid #eee}}
.flag{{color:#b45309;font-size:11px;font-weight:600}}
.empty{{color:#999}}
.notes{{font-size:12px;color:#555;background:#fafafa;border-radius:8px;padding:12px 18px;margin-top:28px}}
</style></head><body>
<h1>Billing report — {report['tenant']}</h1>
<div class="meta">Period: {p['begin'][:10]} → {p['end'][:10]} &nbsp;·&nbsp;
Generated: {report['generated_at'][:19].replace('T',' ')} &nbsp;·&nbsp; Currency: INR</div>
<div class="total">
Bill to date: <b>{pricing.inr(report['bill_to_date'])}</b><br>
<span class="meta">Run-rate: {pricing.inr(report['run_rate_per_month'])}/month
· Projected month-end: {pricing.inr(report['projected_month_end'])}<br>
{c['vms']} VMs · {c['volumes']} volumes · {c['floating_ips']} floating IPs
· ⚠ {c['stopped_vms_still_billing']} stopped + {c['error_vms_still_billing']} error VMs still billing,
{c['detached_volumes']} detached volumes, {c['unassigned_fips']} unassigned FIPs</span>
</div>
<h2>Virtual machines</h2>{vms}
<h2>Volumes</h2>{vols}
<h2>Floating IPs</h2>{fips}
<div class="notes"><b>How billing works</b><ul>{notes}</ul></div>
</body></html>"""