fin-hub/src/fin_hub/cli.py

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())