335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""Fin-hub operator CLI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from fin_hub.coupling.canon import emit_viability_alert
|
|
from fin_hub.coupling.dev_hub import emit_resource_pressure
|
|
from fin_hub.coupling.ops_hub import ServiceCostLine, build_service_cost_report
|
|
from fin_hub.ingest.anthropic import parse_anthropic_billing_csv
|
|
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
|
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
|
|
from fin_hub.services.alerts import evaluate_budget_alerts
|
|
from fin_hub.services.allocation import shared_cost_allocations
|
|
from fin_hub.services.billing import build_billing_basis
|
|
from fin_hub.services.evaluate import evaluate_runway
|
|
from fin_hub.services.evidence import write_runway_evidence
|
|
from fin_hub.services.ledger import (
|
|
client_margin_report,
|
|
default_ledger_path,
|
|
import_csv,
|
|
ledger_stats_json,
|
|
record_engagement_price,
|
|
set_opening_balance,
|
|
)
|
|
from fin_hub.services.runway import compute_runway
|
|
|
|
|
|
def _ledger_path(args: argparse.Namespace) -> Path:
|
|
return Path(args.ledger) if args.ledger else default_ledger_path()
|
|
|
|
|
|
def _cmd_import_cloud(args: argparse.Namespace) -> int:
|
|
rows = parse_cloud_cost_csv(Path(args.path))
|
|
print(json.dumps([row.__dict__ for row in rows], indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def _cmd_import_anthropic(args: argparse.Namespace) -> int:
|
|
rows = parse_anthropic_billing_csv(Path(args.path))
|
|
payload = [
|
|
{
|
|
**row.__dict__,
|
|
"recorded_at": row.recorded_at.isoformat(),
|
|
}
|
|
for row in rows
|
|
]
|
|
print(json.dumps(payload, indent=2))
|
|
return 0
|
|
|
|
|
|
def _cmd_import_hosteurope(args: argparse.Namespace) -> int:
|
|
rows = parse_hosteurope_csv(Path(args.path))
|
|
print(json.dumps([row.__dict__ for row in rows], indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def _cmd_runway(args: argparse.Namespace) -> int:
|
|
burns = [float(v) for v in args.monthly_burn.split(",") if v.strip()]
|
|
runway = compute_runway(
|
|
current_balance=args.balance,
|
|
monthly_burns=burns,
|
|
alert_threshold_months=args.threshold,
|
|
currency=args.currency,
|
|
)
|
|
alerts = evaluate_budget_alerts(
|
|
runway=runway,
|
|
allocated=args.allocated,
|
|
spent=args.spent,
|
|
)
|
|
report = {
|
|
"runway": runway.as_dict(),
|
|
"alerts": [alert.as_dict() for alert in alerts],
|
|
}
|
|
if args.emit:
|
|
report["dev_hub"] = emit_resource_pressure(alerts, api_base=args.api_base)
|
|
report["canon"] = emit_viability_alert(runway, api_base=args.api_base)
|
|
print(json.dumps(report, indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def _cmd_ops_costs(args: argparse.Namespace) -> int:
|
|
lines = [
|
|
ServiceCostLine(
|
|
service_id=row.service_id,
|
|
environment=row.environment,
|
|
period_month=row.period_month,
|
|
amount=row.amount,
|
|
currency=row.currency,
|
|
source=row.source,
|
|
client_id=row.client_id,
|
|
application_id=row.application_id,
|
|
app_instance_id=row.app_instance_id,
|
|
cost_attribution_key=row.cost_attribution_key,
|
|
)
|
|
for row in parse_hosteurope_csv(Path(args.path))
|
|
]
|
|
print(json.dumps(build_service_cost_report(lines), indent=2))
|
|
return 0
|
|
|
|
|
|
def _cmd_ledger_import(args: argparse.Namespace) -> int:
|
|
result = import_csv(
|
|
Path(args.path),
|
|
args.type,
|
|
ledger_path=_ledger_path(args),
|
|
force=args.force,
|
|
)
|
|
print(json.dumps(result.as_dict(), indent=2))
|
|
return 0
|
|
|
|
|
|
def _cmd_ledger_summary(args: argparse.Namespace) -> int:
|
|
print(ledger_stats_json(ledger_path=_ledger_path(args)))
|
|
return 0
|
|
|
|
|
|
def _cmd_ledger_set_balance(args: argparse.Namespace) -> int:
|
|
set_opening_balance(_ledger_path(args), args.balance, currency=args.currency)
|
|
print(
|
|
json.dumps(
|
|
{"opening_balance": args.balance, "currency": args.currency},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_ledger_set_price(args: argparse.Namespace) -> int:
|
|
price = record_engagement_price(
|
|
client_id=args.client,
|
|
application_id=args.application,
|
|
app_instance_id=args.instance,
|
|
period_month=args.period,
|
|
amount=args.amount,
|
|
currency=args.currency,
|
|
source=args.source,
|
|
revision_of=args.revision_of,
|
|
ledger_path=_ledger_path(args),
|
|
)
|
|
print(json.dumps(price.__dict__, indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def _cmd_ledger_margins(args: argparse.Namespace) -> int:
|
|
rows = client_margin_report(ledger_path=_ledger_path(args))
|
|
print(json.dumps([row.as_dict() for row in rows], indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def _cmd_ledger_allocations(args: argparse.Namespace) -> int:
|
|
reports = shared_cost_allocations(ledger_path=_ledger_path(args))
|
|
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def _cmd_ledger_billing_basis(args: argparse.Namespace) -> int:
|
|
export = build_billing_basis(ledger_path=_ledger_path(args))
|
|
print(json.dumps(export.as_dict(), indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def _cmd_evaluate(args: argparse.Namespace) -> int:
|
|
report = evaluate_runway(
|
|
ledger_path=_ledger_path(args),
|
|
opening_balance=args.balance,
|
|
alert_threshold_months=args.threshold,
|
|
currency=args.currency,
|
|
allocated=args.allocated,
|
|
spent=args.spent,
|
|
emit=args.emit,
|
|
api_base=args.api_base,
|
|
)
|
|
print(json.dumps(report, indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
import uvicorn
|
|
|
|
uvicorn.run(
|
|
"fin_hub.app:create_app",
|
|
factory=True,
|
|
host=args.host,
|
|
port=args.port,
|
|
reload=args.reload,
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_evidence(args: argparse.Namespace) -> int:
|
|
output = write_runway_evidence(
|
|
ledger_path=_ledger_path(args),
|
|
output_dir=Path(args.output_dir) if args.output_dir else None,
|
|
opening_balance=args.balance,
|
|
seed_fixtures=args.seed_fixtures,
|
|
alert_threshold_months=args.threshold,
|
|
currency=args.currency,
|
|
)
|
|
print(json.dumps({"written": str(output.resolve())}, indent=2))
|
|
return 0
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Fin Hub operator CLI")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
cloud = sub.add_parser("import-cloud", help="Parse generic cloud cost CSV")
|
|
cloud.add_argument("path")
|
|
cloud.set_defaults(func=_cmd_import_cloud)
|
|
|
|
anthropic = sub.add_parser("import-anthropic", help="Parse Anthropic billing CSV")
|
|
anthropic.add_argument("path")
|
|
anthropic.set_defaults(func=_cmd_import_anthropic)
|
|
|
|
hosteurope = sub.add_parser("import-hosteurope", help="Parse HostEurope invoice CSV")
|
|
hosteurope.add_argument("path")
|
|
hosteurope.set_defaults(func=_cmd_import_hosteurope)
|
|
|
|
runway = sub.add_parser("runway", help="Compute runway and optional alerts")
|
|
runway.add_argument("--balance", type=float, required=True)
|
|
runway.add_argument("--monthly-burn", required=True, help="Comma-separated monthly burn values")
|
|
runway.add_argument("--threshold", type=float, default=3.0)
|
|
runway.add_argument("--currency", default="EUR")
|
|
runway.add_argument("--allocated", type=float)
|
|
runway.add_argument("--spent", type=float)
|
|
runway.add_argument("--emit", action="store_true", help="Emit fin→dev and fin→canon signals")
|
|
runway.add_argument("--api-base")
|
|
runway.set_defaults(func=_cmd_runway)
|
|
|
|
ops = sub.add_parser("ops-costs", help="Build per-service cost attribution report")
|
|
ops.add_argument("path")
|
|
ops.set_defaults(func=_cmd_ops_costs)
|
|
|
|
ledger = sub.add_parser("ledger", help="Persistent SQLite cost ledger")
|
|
ledger_sub = ledger.add_subparsers(dest="ledger_command", required=True)
|
|
|
|
ledger_import = ledger_sub.add_parser("import", help="Import a cost CSV into the ledger")
|
|
ledger_import.add_argument("type", choices=["cloud", "anthropic", "hosteurope"])
|
|
ledger_import.add_argument("path")
|
|
ledger_import.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)")
|
|
ledger_import.add_argument("--force", action="store_true", help="Re-import even if file unchanged")
|
|
ledger_import.set_defaults(func=_cmd_ledger_import)
|
|
|
|
ledger_summary = ledger_sub.add_parser("summary", help="Monthly rollup summary from ledger")
|
|
ledger_summary.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)")
|
|
ledger_summary.set_defaults(func=_cmd_ledger_summary)
|
|
|
|
ledger_balance = ledger_sub.add_parser("set-balance", help="Store opening cash balance in ledger meta")
|
|
ledger_balance.add_argument("balance", type=float)
|
|
ledger_balance.add_argument("--currency", default="EUR")
|
|
ledger_balance.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)")
|
|
ledger_balance.set_defaults(func=_cmd_ledger_set_balance)
|
|
|
|
ledger_price = ledger_sub.add_parser(
|
|
"set-price", help="Record or revise an engagement price for a reporting period"
|
|
)
|
|
ledger_price.add_argument("--client", required=True)
|
|
ledger_price.add_argument("--application", required=True)
|
|
ledger_price.add_argument("--instance", required=True)
|
|
ledger_price.add_argument("--period", required=True, help="Reporting month in YYYY-MM")
|
|
ledger_price.add_argument("--amount", required=True, type=float)
|
|
ledger_price.add_argument("--currency", default="EUR")
|
|
ledger_price.add_argument("--source", required=True)
|
|
ledger_price.add_argument("--revision-of")
|
|
ledger_price.add_argument("--ledger", help="Ledger database path")
|
|
ledger_price.set_defaults(func=_cmd_ledger_set_price)
|
|
|
|
ledger_margins = ledger_sub.add_parser(
|
|
"margins", help="Report revenue, attributed cost, and margin by engagement"
|
|
)
|
|
ledger_margins.add_argument("--ledger", help="Ledger database path")
|
|
ledger_margins.set_defaults(func=_cmd_ledger_margins)
|
|
|
|
ledger_allocations = ledger_sub.add_parser(
|
|
"allocations",
|
|
help="Reconcile resource-control allocation evidence to booked facts",
|
|
)
|
|
ledger_allocations.add_argument("--ledger", help="Ledger database path")
|
|
ledger_allocations.set_defaults(func=_cmd_ledger_allocations)
|
|
|
|
billing_basis = ledger_sub.add_parser(
|
|
"billing-basis",
|
|
help="Export deterministic per-client reporting basis (never an invoice)",
|
|
)
|
|
billing_basis.add_argument("--ledger", help="Ledger database path")
|
|
billing_basis.set_defaults(func=_cmd_ledger_billing_basis)
|
|
|
|
evaluate = sub.add_parser(
|
|
"evaluate",
|
|
help="Evaluate runway from ledger burns (cron/systemd friendly)",
|
|
)
|
|
evaluate.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)")
|
|
evaluate.add_argument("--balance", type=float, help="Override stored opening balance")
|
|
evaluate.add_argument("--threshold", type=float, default=3.0)
|
|
evaluate.add_argument("--currency", default="EUR")
|
|
evaluate.add_argument("--allocated", type=float)
|
|
evaluate.add_argument("--spent", type=float)
|
|
evaluate.add_argument("--emit", action="store_true", help="Emit fin→dev and fin→canon signals")
|
|
evaluate.add_argument("--api-base")
|
|
evaluate.set_defaults(func=_cmd_evaluate)
|
|
|
|
evidence = sub.add_parser("evidence", help="Write runway dogfood evidence artefact")
|
|
evidence.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)")
|
|
evidence.add_argument("--balance", type=float, default=12000.0)
|
|
evidence.add_argument("--threshold", type=float, default=3.0)
|
|
evidence.add_argument("--currency", default="EUR")
|
|
evidence.add_argument("--output-dir", help="Output directory (default: docs/evidence)")
|
|
evidence.add_argument(
|
|
"--seed-fixtures",
|
|
action="store_true",
|
|
help="Import tests/fixtures CSVs before generating the report",
|
|
)
|
|
evidence.set_defaults(func=_cmd_evidence)
|
|
|
|
serve = sub.add_parser("serve", help="Run the HTTP read API")
|
|
serve.add_argument("--host", default="127.0.0.1")
|
|
serve.add_argument("--port", type=int, default=8080)
|
|
serve.add_argument("--reload", action="store_true")
|
|
serve.set_defaults(func=_cmd_serve)
|
|
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|