diff --git a/reporting.py b/reporting.py new file mode 100644 index 0000000..f7ff86c --- /dev/null +++ b/reporting.py @@ -0,0 +1,256 @@ +"""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"{h}" for h in headers) + body = "" + for r in rows: + cells = "".join(f"{c}" for c in r) + body += f"{cells}" + if not body: + body = f"none" + return f"{head}{body}
" + + +def render_html(report: dict) -> str: + p = report["period"] + flags = lambda r: ( + f"
{' '.join(r['flags'])}" 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"
  • {n}
  • " for n in report["notes"]) + c = report["counts"] + + return f""" +Billing report — {report['tenant']} + +

    Billing report — {report['tenant']}

    +
    Period: {p['begin'][:10]} → {p['end'][:10]}  ·  +Generated: {report['generated_at'][:19].replace('T',' ')}  ·  Currency: INR
    + +
    +Bill to date: {pricing.inr(report['bill_to_date'])}
    +Run-rate: {pricing.inr(report['run_rate_per_month'])}/month +· Projected month-end: {pricing.inr(report['projected_month_end'])}
    +{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
    +
    + +

    Virtual machines

    {vms} +

    Volumes

    {vols} +

    Floating IPs

    {fips} + +
    How billing works
    +"""