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