Implement fin-hub T24-T26: ingest, runway, coupling, RaaS packaging
Add CSV importers, runway calculator, budget alerts, cross-hub coupling emitters, finhub CLI, and raas-mvp-packaging docs with full test coverage.
This commit is contained in:
parent
b6993d4a05
commit
1abbbe85e4
27 changed files with 835 additions and 3 deletions
32
CLAUDE.custodian.md
Normal file
32
CLAUDE.custodian.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<!--
|
||||
CUSTODIAN SUGGESTION — generated by `custodian register-project`
|
||||
|
||||
This file was written by the Custodian State Hub registration workflow.
|
||||
It contains the standard State Hub integration block for this repository.
|
||||
|
||||
A Claude Code agent in this repo should:
|
||||
1. Read this file and the existing CLAUDE.md side by side
|
||||
2. Integrate the sections below into CLAUDE.md — preserve all existing
|
||||
project-specific conventions; add or update the hub integration block
|
||||
3. Delete this file once integration is complete and commit the result
|
||||
|
||||
Do NOT add this file to .gitignore. It is a temporary artefact that signals
|
||||
pending integration work to the repo agent.
|
||||
-->
|
||||
|
||||
# DEPRECATED — see project_rules/
|
||||
|
||||
This file was the monolithic CLAUDE.md template. It has been replaced by the
|
||||
modular @-import structure in `scripts/project_rules/`.
|
||||
|
||||
`register_project.sh` now generates:
|
||||
- `CLAUDE.md` — thin @-import index (9 lines)
|
||||
- `.claude/rules/repo-identity.md` — purpose, domain, slug, topic ID
|
||||
- `.claude/rules/session-protocol.md`— orient → inbox → workplans → brief
|
||||
- `.claude/rules/first-session.md` — bootstrap flow (delete once past FSP)
|
||||
- `.claude/rules/workplan-convention.md` — prefix, location, delegates to global
|
||||
- `.claude/rules/stack-and-commands.md` — language, deps, dev commands (stub)
|
||||
- `.claude/rules/architecture.md` — design overview (stub)
|
||||
- `.claude/rules/repo-boundary.md` — what this repo does NOT own (stub)
|
||||
|
||||
See `ops-bridge/.claude/` for a complete reference example.
|
||||
|
|
@ -19,8 +19,14 @@ tracked in `CUST-WP-0025-T23`–`T24`.
|
|||
cd /home/worsch/fin-hub
|
||||
uv sync
|
||||
uv run pytest
|
||||
uv run finhub runway --balance 12000 --monthly-burn 2100,2200,2000
|
||||
uv run finhub import-cloud tests/fixtures/cloud-costs.csv
|
||||
uv run finhub ops-costs tests/fixtures/hosteurope.csv
|
||||
```
|
||||
|
||||
Cross-hub coupling (`--emit`) posts non-secret progress events to dev-hub when
|
||||
`STATE_HUB_API` is reachable.
|
||||
|
||||
## Related Workplans
|
||||
|
||||
- `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella
|
||||
|
|
|
|||
66
docs/raas-mvp-packaging.md
Normal file
66
docs/raas-mvp-packaging.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Railiance-as-a-Service — MVP Packaging (CUST-WP-0025-T26)
|
||||
|
||||
Status: draft v0.1 (2026-07-08)
|
||||
|
||||
## Tier Matrix
|
||||
|
||||
| Capability | Self-hosted | Managed | Fully operated |
|
||||
| --- | --- | --- | --- |
|
||||
| k3s + GitOps templates | ✓ OSS | ✓ | ✓ |
|
||||
| Observability (metrics/logs/traces) | docs | ✓ | ✓ |
|
||||
| Backup + restore drill evidence | customer-run | quarterly | monthly |
|
||||
| Incident response | community | business hours | 24×7 option |
|
||||
| FOS hub setup (dev/ops/fin) | docs | dev+ops | full federation |
|
||||
| Runway/cost dashboard | — | optional add-on | ✓ |
|
||||
| SLA | best effort | 99.5% monthly | contractual |
|
||||
|
||||
## Landing Page Content (Draft)
|
||||
|
||||
**Headline:** Sovereign DevOps for EU SMEs — without vendor lock-in.
|
||||
|
||||
**Subhead:** Railiance-as-a-Service packages reproducible Kubernetes operations,
|
||||
observability, and FOS-aligned governance so your team can focus on product work
|
||||
instead of founder-only ops.
|
||||
|
||||
**Proof points:**
|
||||
|
||||
- EU-hostable infrastructure with documented restore drills
|
||||
- GDPR-conscious operations model (data stays under your control)
|
||||
- VSM-based coordination — not another opaque SaaS dashboard
|
||||
- Open-core templates; no mandatory hyperscaler control plane
|
||||
|
||||
**CTA:** Book an architecture review → scoped managed cutover proposal.
|
||||
|
||||
## Onboarding Workflow
|
||||
|
||||
1. **Discovery call** — scope workloads, compliance constraints, team size.
|
||||
2. **Architecture review** — map domains, hubs, and OAS substrate requirements.
|
||||
3. **Provision** — deploy k3s cluster (Railiance-managed or customer-owned).
|
||||
4. **GitOps baseline** — ArgoCD apps, secrets custody via OpenBao path.
|
||||
5. **Observability** — Grafana/Loki/Tempo (or customer-preferred stack).
|
||||
6. **Restore drill** — execute and capture evidence within 14 days of go-live.
|
||||
7. **Hub registration** — register repos in dev-hub; ops evidence on Core Hub.
|
||||
8. **Handover** — runbooks, access model, escalation contacts.
|
||||
|
||||
## Legal and GmbH Framework (Draft — Not Legal Advice)
|
||||
|
||||
| Topic | v0.1 posture |
|
||||
| --- | --- |
|
||||
| **Entity** | Pre-revenue consulting may invoice as founder/freelance; GmbH formation triggered at €10k annual revenue or first managed customer (see bootstrap-protocol). |
|
||||
| **Liability** | Managed tier: liability capped at 12 months fees; exclude indirect/consequential damages; customer maintains data custody. |
|
||||
| **SLA** | Managed: 99.5% control-plane availability monthly; credits capped at 25% monthly fee. Fully operated: negotiable. |
|
||||
| **Data processing** | DPA required for managed/operated tiers; subprocessors listed in canon. |
|
||||
| **Insurance** | Cyber/liability policy review before first external managed customer. |
|
||||
|
||||
## First Customer Acquisition Strategy
|
||||
|
||||
1. **Internal dogfood** — complete Railiance restore drill + fin-hub runway evidence.
|
||||
2. **Founder network** — 3 architecture review offers to EU technical SMEs with DSGVO pressure.
|
||||
3. **Consulting wedge** — fixed-scope "sovereign ops assessment" (2–5 days) leading to managed tier.
|
||||
4. **Content** — publish restore-drill case study (non-secret evidence only).
|
||||
5. **Partners** — EU hosting/legal partners for GmbH + DPA handoff.
|
||||
|
||||
## References
|
||||
|
||||
- `the-custodian/canon/projects/railiance/business-model-canvas_v0.1.md`
|
||||
- `the-custodian/canon/constitution/bootstrap-protocol_v0.1.md`
|
||||
|
|
@ -24,6 +24,9 @@ packages = ["fin_hub"]
|
|||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
|
||||
[project.scripts]
|
||||
finhub = "fin_hub.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
|
|
|
|||
126
src/fin_hub/cli.py
Normal file
126
src/fin_hub/cli.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""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.runway import compute_runway
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
for row in parse_hosteurope_csv(Path(args.path))
|
||||
]
|
||||
print(json.dumps(build_service_cost_report(lines), 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)
|
||||
|
||||
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())
|
||||
11
src/fin_hub/coupling/__init__.py
Normal file
11
src/fin_hub/coupling/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""FOS §9 cross-hub coupling emitters."""
|
||||
|
||||
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 build_service_cost_report
|
||||
|
||||
__all__ = [
|
||||
"build_service_cost_report",
|
||||
"emit_resource_pressure",
|
||||
"emit_viability_alert",
|
||||
]
|
||||
38
src/fin_hub/coupling/canon.py
Normal file
38
src/fin_hub/coupling/canon.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""fin→canon: viability alerts when runway breaches System 5 threshold."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hub_core.events import RISK_ESCALATED
|
||||
|
||||
from fin_hub.coupling.common import default_dev_hub_api_base, post_progress_event
|
||||
from fin_hub.services.runway import RunwayResult
|
||||
|
||||
|
||||
def emit_viability_alert(
|
||||
runway: RunwayResult,
|
||||
*,
|
||||
api_base: str | None = None,
|
||||
canon_workplan_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not runway.below_threshold or runway.monthly_burn <= 0:
|
||||
return {"ok": True, "skipped": True, "reason": "runway above threshold"}
|
||||
base = api_base or default_dev_hub_api_base()
|
||||
return post_progress_event(
|
||||
api_base=base,
|
||||
event_type=RISK_ESCALATED,
|
||||
summary=(
|
||||
f"[fin→canon] Runway {runway.months_remaining:.1f} months below "
|
||||
f"{runway.alert_threshold_months:.1f}-month viability threshold"
|
||||
),
|
||||
detail={
|
||||
"source_hub": "fin-hub",
|
||||
"target_hub": "canon",
|
||||
"signal": "viability_alert",
|
||||
"runway": runway.as_dict(),
|
||||
"escalation_target": "system_5",
|
||||
},
|
||||
author="fin-hub",
|
||||
workplan_id=canon_workplan_id,
|
||||
)
|
||||
46
src/fin_hub/coupling/common.py
Normal file
46
src/fin_hub/coupling/common.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""HTTP helpers for cross-hub progress emission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
def post_progress_event(
|
||||
*,
|
||||
api_base: str,
|
||||
event_type: str,
|
||||
summary: str,
|
||||
detail: dict[str, Any] | None = None,
|
||||
author: str = "fin-hub",
|
||||
workplan_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"event_type": event_type,
|
||||
"summary": summary,
|
||||
"author": author,
|
||||
}
|
||||
if detail:
|
||||
payload["detail"] = detail
|
||||
if workplan_id:
|
||||
payload["workplan_id"] = workplan_id
|
||||
url = f"{api_base.rstrip('/')}/progress/"
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.URLError as exc:
|
||||
return {"ok": False, "error": str(exc), "queued": False}
|
||||
|
||||
|
||||
def default_dev_hub_api_base() -> str:
|
||||
return os.environ.get("STATE_HUB_API", os.environ.get("API_BASE", "http://127.0.0.1:8000"))
|
||||
37
src/fin_hub/coupling/dev_hub.py
Normal file
37
src/fin_hub/coupling/dev_hub.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""fin→dev: resource pressure signals to dev-hub (State Hub progress)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hub_core.events import ALERT_RAISED
|
||||
|
||||
from fin_hub.coupling.common import default_dev_hub_api_base, post_progress_event
|
||||
from fin_hub.services.alerts import BudgetAlert
|
||||
|
||||
|
||||
def emit_resource_pressure(
|
||||
alerts: list[BudgetAlert],
|
||||
*,
|
||||
api_base: str | None = None,
|
||||
domain_slug: str = "infotech",
|
||||
) -> list[dict[str, Any]]:
|
||||
base = api_base or default_dev_hub_api_base()
|
||||
emitted: list[dict[str, Any]] = []
|
||||
for alert in alerts:
|
||||
if alert.code not in {"budget_pressure", "runway_below_threshold"}:
|
||||
continue
|
||||
result = post_progress_event(
|
||||
api_base=base,
|
||||
event_type=ALERT_RAISED,
|
||||
summary=f"[fin→dev:{domain_slug}] {alert.summary}",
|
||||
detail={
|
||||
"source_hub": "fin-hub",
|
||||
"target_hub": "dev-hub",
|
||||
"signal": "resource_pressure",
|
||||
"alert": alert.as_dict(),
|
||||
},
|
||||
author="fin-hub",
|
||||
)
|
||||
emitted.append(result)
|
||||
return emitted
|
||||
47
src/fin_hub/coupling/ops_hub.py
Normal file
47
src/fin_hub/coupling/ops_hub.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""fin→ops: infrastructure cost attribution per service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceCostLine:
|
||||
service_id: str
|
||||
environment: str
|
||||
period_month: str
|
||||
amount: float
|
||||
currency: str
|
||||
source: str
|
||||
|
||||
|
||||
def build_service_cost_report(
|
||||
lines: Iterable[ServiceCostLine],
|
||||
) -> dict:
|
||||
by_service: dict[str, dict] = {}
|
||||
totals_by_month: dict[str, float] = defaultdict(float)
|
||||
for line in lines:
|
||||
bucket = by_service.setdefault(
|
||||
line.service_id,
|
||||
{
|
||||
"service_id": line.service_id,
|
||||
"environment": line.environment,
|
||||
"currency": line.currency,
|
||||
"months": {},
|
||||
"total": 0.0,
|
||||
},
|
||||
)
|
||||
month_total = bucket["months"].get(line.period_month, 0.0) + line.amount
|
||||
bucket["months"][line.period_month] = month_total
|
||||
bucket["total"] += line.amount
|
||||
totals_by_month[line.period_month] += line.amount
|
||||
services = sorted(by_service.values(), key=lambda item: item["total"], reverse=True)
|
||||
return {
|
||||
"source_hub": "fin-hub",
|
||||
"target_hub": "ops-hub",
|
||||
"signal": "service_cost_attribution",
|
||||
"services": services,
|
||||
"totals_by_month": dict(sorted(totals_by_month.items())),
|
||||
}
|
||||
11
src/fin_hub/ingest/__init__.py
Normal file
11
src/fin_hub/ingest/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""CSV and billing export ingestion for fin-hub."""
|
||||
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"parse_anthropic_billing_csv",
|
||||
"parse_cloud_cost_csv",
|
||||
"parse_hosteurope_csv",
|
||||
]
|
||||
28
src/fin_hub/ingest/_csv.py
Normal file
28
src/fin_hub/ingest/_csv.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Shared CSV helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_csv_rows(path: Path) -> list[dict[str, str]]:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
reader = csv.DictReader(text.splitlines())
|
||||
return [{k.strip(): (v or "").strip() for k, v in row.items() if k} for row in reader]
|
||||
|
||||
|
||||
def pick(row: dict[str, str], *names: str) -> str:
|
||||
lowered = {k.lower(): v for k, v in row.items()}
|
||||
for name in names:
|
||||
value = lowered.get(name.lower())
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def parse_amount(value: str) -> float:
|
||||
cleaned = value.replace("€", "").replace("EUR", "").replace(",", ".").strip()
|
||||
if not cleaned:
|
||||
return 0.0
|
||||
return float(cleaned)
|
||||
52
src/fin_hub/ingest/anthropic.py
Normal file
52
src/fin_hub/ingest/anthropic.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Anthropic billing export ingestion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenSpendRow:
|
||||
provider: str
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost: float
|
||||
currency: str
|
||||
session_id: str | None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
def parse_anthropic_billing_csv(path: Path, *, default_currency: str = "USD") -> list[TokenSpendRow]:
|
||||
rows: list[TokenSpendRow] = []
|
||||
for row in read_csv_rows(path):
|
||||
model = pick(row, "model", "model_name")
|
||||
cost_raw = pick(row, "cost", "amount", "total_cost", "usage_cost_usd")
|
||||
if not model or not cost_raw:
|
||||
continue
|
||||
tokens_in = int(pick(row, "input_tokens", "tokens_in", "prompt_tokens") or "0")
|
||||
tokens_out = int(pick(row, "output_tokens", "tokens_out", "completion_tokens") or "0")
|
||||
recorded_raw = pick(row, "date", "usage_date", "recorded_at", "timestamp")
|
||||
recorded_at = (
|
||||
datetime.fromisoformat(recorded_raw.replace("Z", "+00:00"))
|
||||
if recorded_raw
|
||||
else datetime.utcnow()
|
||||
)
|
||||
currency = pick(row, "currency") or default_currency
|
||||
rows.append(
|
||||
TokenSpendRow(
|
||||
provider="anthropic",
|
||||
model=model,
|
||||
tokens_in=tokens_in,
|
||||
tokens_out=tokens_out,
|
||||
cost=parse_amount(cost_raw),
|
||||
currency=currency.upper()[:3],
|
||||
session_id=pick(row, "session_id", "request_id") or None,
|
||||
recorded_at=recorded_at,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
47
src/fin_hub/ingest/cloud.py
Normal file
47
src/fin_hub/ingest/cloud.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Generic cloud cost CSV ingestion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CloudCostRow:
|
||||
service: str
|
||||
amount: float
|
||||
currency: str
|
||||
period_month: str
|
||||
incurred_on: date | None
|
||||
source: str = "cloud_csv"
|
||||
|
||||
|
||||
def parse_cloud_cost_csv(path: Path, *, default_currency: str = "EUR") -> list[CloudCostRow]:
|
||||
rows: list[CloudCostRow] = []
|
||||
for row in read_csv_rows(path):
|
||||
service = pick(row, "service", "service_name", "resource", "description")
|
||||
amount_raw = pick(row, "amount", "cost", "total", "spend")
|
||||
if not service or not amount_raw:
|
||||
continue
|
||||
period = pick(row, "period_month", "month", "billing_period")
|
||||
if not period:
|
||||
incurred = pick(row, "date", "incurred_on", "usage_date")
|
||||
period = incurred[:7] if len(incurred) >= 7 else datetime.utcnow().strftime("%Y-%m")
|
||||
incurred_on = None
|
||||
incurred_raw = pick(row, "date", "incurred_on", "usage_date")
|
||||
if incurred_raw:
|
||||
incurred_on = date.fromisoformat(incurred_raw[:10])
|
||||
currency = pick(row, "currency") or default_currency
|
||||
rows.append(
|
||||
CloudCostRow(
|
||||
service=service,
|
||||
amount=parse_amount(amount_raw),
|
||||
currency=currency.upper()[:3],
|
||||
period_month=period[:7],
|
||||
incurred_on=incurred_on,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
52
src/fin_hub/ingest/hosteurope.py
Normal file
52
src/fin_hub/ingest/hosteurope.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""HostEurope invoice CSV ingestion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostEuropeCostRow:
|
||||
service_id: str
|
||||
title: str
|
||||
amount: float
|
||||
currency: str
|
||||
period_month: str
|
||||
incurred_on: date | None
|
||||
environment: str = "production"
|
||||
source: str = "hosteurope"
|
||||
|
||||
|
||||
def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[HostEuropeCostRow]:
|
||||
rows: list[HostEuropeCostRow] = []
|
||||
for row in read_csv_rows(path):
|
||||
title = pick(row, "product", "description", "service", "title")
|
||||
amount_raw = pick(row, "amount", "net", "total", "price")
|
||||
if not title or not amount_raw:
|
||||
continue
|
||||
service_id = pick(row, "service_id", "product_id") or title.lower().replace(" ", "-")[:128]
|
||||
period = pick(row, "period_month", "month", "billing_period")
|
||||
incurred_raw = pick(row, "date", "invoice_date", "incurred_on")
|
||||
incurred_on = date.fromisoformat(incurred_raw[:10]) if incurred_raw else None
|
||||
if not period and incurred_on:
|
||||
period = incurred_on.strftime("%Y-%m")
|
||||
if not period:
|
||||
continue
|
||||
currency = pick(row, "currency") or default_currency
|
||||
environment = pick(row, "environment", "env") or "production"
|
||||
rows.append(
|
||||
HostEuropeCostRow(
|
||||
service_id=service_id,
|
||||
title=title,
|
||||
amount=parse_amount(amount_raw),
|
||||
currency=currency.upper()[:3],
|
||||
period_month=period[:7],
|
||||
incurred_on=incurred_on,
|
||||
environment=environment,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
"""Fin-specific SQLAlchemy models (T23 implementation target)."""
|
||||
|
||||
from fin_hub.models.budget import Budget, BurnRate, Commitment, RunwayProjection, TokenSpend
|
||||
from fin_hub.models.service_cost import ServiceCost
|
||||
|
||||
__all__ = [
|
||||
"Budget",
|
||||
"Commitment",
|
||||
"BurnRate",
|
||||
"Commitment",
|
||||
"RunwayProjection",
|
||||
"ServiceCost",
|
||||
"TokenSpend",
|
||||
]
|
||||
26
src/fin_hub/models/service_cost.py
Normal file
26
src/fin_hub/models/service_cost.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Per-service infrastructure cost attribution (fin→ops coupling)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Float, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from hub_core.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class ServiceCost(Base, TimestampMixin):
|
||||
__tablename__ = "fin_service_costs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
service_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
environment: Mapped[str] = mapped_column(String(64), nullable=False, default="production")
|
||||
period_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True)
|
||||
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||
source: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
incurred_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
6
src/fin_hub/services/__init__.py
Normal file
6
src/fin_hub/services/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Fin-hub domain services."""
|
||||
|
||||
from fin_hub.services.alerts import evaluate_budget_alerts
|
||||
from fin_hub.services.runway import RunwayResult, compute_runway
|
||||
|
||||
__all__ = ["RunwayResult", "compute_runway", "evaluate_budget_alerts"]
|
||||
60
src/fin_hub/services/alerts.py
Normal file
60
src/fin_hub/services/alerts.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Budget and runway alert evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fin_hub.services.runway import RunwayResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BudgetAlert:
|
||||
code: str
|
||||
severity: str
|
||||
summary: str
|
||||
detail: dict
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"code": self.code,
|
||||
"severity": self.severity,
|
||||
"summary": self.summary,
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_budget_alerts(
|
||||
*,
|
||||
runway: RunwayResult,
|
||||
allocated: float | None = None,
|
||||
spent: float | None = None,
|
||||
) -> list[BudgetAlert]:
|
||||
alerts: list[BudgetAlert] = []
|
||||
if runway.below_threshold and runway.monthly_burn > 0:
|
||||
alerts.append(
|
||||
BudgetAlert(
|
||||
code="runway_below_threshold",
|
||||
severity="high",
|
||||
summary=(
|
||||
f"Projected runway {runway.months_remaining:.1f} months "
|
||||
f"is below threshold {runway.alert_threshold_months:.1f}"
|
||||
),
|
||||
detail=runway.as_dict(),
|
||||
)
|
||||
)
|
||||
if allocated is not None and spent is not None and allocated > 0:
|
||||
utilisation = spent / allocated
|
||||
if utilisation >= 0.8:
|
||||
alerts.append(
|
||||
BudgetAlert(
|
||||
code="budget_pressure",
|
||||
severity="medium" if utilisation < 1.0 else "high",
|
||||
summary=f"Budget utilisation at {utilisation * 100:.0f}%",
|
||||
detail={
|
||||
"allocated": allocated,
|
||||
"spent": spent,
|
||||
"utilisation": utilisation,
|
||||
},
|
||||
)
|
||||
)
|
||||
return alerts
|
||||
56
src/fin_hub/services/runway.py
Normal file
56
src/fin_hub/services/runway.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Runway calculator with burn-rate projection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunwayResult:
|
||||
current_balance: float
|
||||
monthly_burn: float
|
||||
months_remaining: float
|
||||
alert_threshold_months: float
|
||||
below_threshold: bool
|
||||
currency: str
|
||||
computed_at: datetime
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"current_balance": self.current_balance,
|
||||
"monthly_burn": self.monthly_burn,
|
||||
"months_remaining": self.months_remaining,
|
||||
"alert_threshold_months": self.alert_threshold_months,
|
||||
"below_threshold": self.below_threshold,
|
||||
"currency": self.currency,
|
||||
"computed_at": self.computed_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def compute_runway(
|
||||
*,
|
||||
current_balance: float,
|
||||
monthly_burns: list[float],
|
||||
alert_threshold_months: float = 3.0,
|
||||
currency: str = "EUR",
|
||||
) -> RunwayResult:
|
||||
if not monthly_burns:
|
||||
monthly_burn = 0.0
|
||||
else:
|
||||
recent = monthly_burns[-3:]
|
||||
monthly_burn = sum(recent) / len(recent)
|
||||
if monthly_burn <= 0:
|
||||
months_remaining = float("inf") if current_balance > 0 else 0.0
|
||||
else:
|
||||
months_remaining = current_balance / monthly_burn
|
||||
below_threshold = months_remaining < alert_threshold_months
|
||||
return RunwayResult(
|
||||
current_balance=current_balance,
|
||||
monthly_burn=monthly_burn,
|
||||
months_remaining=months_remaining,
|
||||
alert_threshold_months=alert_threshold_months,
|
||||
below_threshold=below_threshold,
|
||||
currency=currency,
|
||||
computed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
3
tests/fixtures/anthropic-billing.csv
vendored
Normal file
3
tests/fixtures/anthropic-billing.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
model,input_tokens,output_tokens,cost,currency,usage_date
|
||||
claude-sonnet-4-20250514,12000,3000,1.25,USD,2026-06-10
|
||||
claude-haiku-3-20240307,50000,2000,0.40,USD,2026-06-11
|
||||
|
4
tests/fixtures/cloud-costs.csv
vendored
Normal file
4
tests/fixtures/cloud-costs.csv
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
service,amount,currency,date
|
||||
state-hub,42.50,EUR,2026-06-01
|
||||
core-hub,18.00,EUR,2026-06-15
|
||||
gitea,25.00,EUR,2026-06-20
|
||||
|
3
tests/fixtures/hosteurope.csv
vendored
Normal file
3
tests/fixtures/hosteurope.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
product,amount,currency,invoice_date,environment
|
||||
Dedicated Server M,89.90,EUR,2026-06-01,production
|
||||
Backup Storage,9.90,EUR,2026-06-01,production
|
||||
|
24
tests/test_coupling.py
Normal file
24
tests/test_coupling.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import pytest
|
||||
|
||||
from fin_hub.coupling.ops_hub import ServiceCostLine, build_service_cost_report
|
||||
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_build_service_cost_report_groups_by_service():
|
||||
rows = parse_hosteurope_csv(Path(__file__).parent / "fixtures" / "hosteurope.csv")
|
||||
lines = [
|
||||
ServiceCostLine(
|
||||
service_id=row.service_id,
|
||||
environment=row.environment,
|
||||
period_month=row.period_month,
|
||||
amount=row.amount,
|
||||
currency=row.currency,
|
||||
source=row.source,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
report = build_service_cost_report(lines)
|
||||
assert report["signal"] == "service_cost_attribution"
|
||||
assert len(report["services"]) == 2
|
||||
assert report["totals_by_month"]["2026-06"] == pytest.approx(99.8)
|
||||
28
tests/test_ingest.py
Normal file
28
tests/test_ingest.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def test_parse_cloud_cost_csv():
|
||||
rows = parse_cloud_cost_csv(FIXTURES / "cloud-costs.csv")
|
||||
assert len(rows) == 3
|
||||
assert rows[0].service == "state-hub"
|
||||
assert rows[0].amount == 42.5
|
||||
|
||||
|
||||
def test_parse_anthropic_billing_csv():
|
||||
rows = parse_anthropic_billing_csv(FIXTURES / "anthropic-billing.csv")
|
||||
assert len(rows) == 2
|
||||
assert rows[0].provider == "anthropic"
|
||||
assert rows[0].tokens_in == 12000
|
||||
|
||||
|
||||
def test_parse_hosteurope_csv():
|
||||
rows = parse_hosteurope_csv(FIXTURES / "hosteurope.csv")
|
||||
assert len(rows) == 2
|
||||
assert rows[0].service_id == "dedicated-server-m"
|
||||
assert rows[0].period_month == "2026-06"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Smoke tests for fin-hub model registration."""
|
||||
|
||||
from fin_hub.models import Budget, BurnRate, Commitment, RunwayProjection, TokenSpend
|
||||
from fin_hub.models import Budget, BurnRate, Commitment, RunwayProjection, ServiceCost, TokenSpend
|
||||
from hub_core.models.base import Base
|
||||
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ def test_fin_models_register_on_metadata():
|
|||
assert "fin_burn_rates" in tables
|
||||
assert "fin_runway_projections" in tables
|
||||
assert "fin_token_spends" in tables
|
||||
assert "fin_service_costs" in tables
|
||||
|
||||
|
||||
def test_model_classes_importable():
|
||||
|
|
@ -18,4 +19,5 @@ def test_model_classes_importable():
|
|||
assert Commitment.__tablename__ == "fin_commitments"
|
||||
assert BurnRate.__tablename__ == "fin_burn_rates"
|
||||
assert RunwayProjection.__tablename__ == "fin_runway_projections"
|
||||
assert TokenSpend.__tablename__ == "fin_token_spends"
|
||||
assert TokenSpend.__tablename__ == "fin_token_spends"
|
||||
assert ServiceCost.__tablename__ == "fin_service_costs"
|
||||
16
tests/test_runway.py
Normal file
16
tests/test_runway.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from fin_hub.services.alerts import evaluate_budget_alerts
|
||||
from fin_hub.services.runway import compute_runway
|
||||
|
||||
|
||||
def test_compute_runway_below_threshold():
|
||||
runway = compute_runway(current_balance=6000, monthly_burns=[2000, 2200, 2100], alert_threshold_months=3.0)
|
||||
assert runway.months_remaining < 3.0
|
||||
assert runway.below_threshold is True
|
||||
|
||||
|
||||
def test_budget_alerts_include_runway_and_utilisation():
|
||||
runway = compute_runway(current_balance=6000, monthly_burns=[2000, 2200, 2100])
|
||||
alerts = evaluate_budget_alerts(runway=runway, allocated=10000, spent=8500)
|
||||
codes = {alert.code for alert in alerts}
|
||||
assert "runway_below_threshold" in codes
|
||||
assert "budget_pressure" in codes
|
||||
Loading…
Add table
Add a link
Reference in a new issue