"""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
    """