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:
tegwick 2026-07-08 00:59:39 +02:00
parent b6993d4a05
commit 1abbbe85e4
27 changed files with 835 additions and 3 deletions

32
CLAUDE.custodian.md Normal file
View 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.

View file

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

View 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" (25 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`

View file

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

View 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",
]

View 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,
)

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

View 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

View 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())),
}

View 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",
]

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

View 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

View 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

View 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

View file

@ -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",
]

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

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

View 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

View 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
View 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
1 model input_tokens output_tokens cost currency usage_date
2 claude-sonnet-4-20250514 12000 3000 1.25 USD 2026-06-10
3 claude-haiku-3-20240307 50000 2000 0.40 USD 2026-06-11

4
tests/fixtures/cloud-costs.csv vendored Normal file
View 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
1 service amount currency date
2 state-hub 42.50 EUR 2026-06-01
3 core-hub 18.00 EUR 2026-06-15
4 gitea 25.00 EUR 2026-06-20

3
tests/fixtures/hosteurope.csv vendored Normal file
View 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
1 product amount currency invoice_date environment
2 Dedicated Server M 89.90 EUR 2026-06-01 production
3 Backup Storage 9.90 EUR 2026-06-01 production

24
tests/test_coupling.py Normal file
View 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
View 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"

View file

@ -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():
@ -19,3 +20,4 @@ def test_model_classes_importable():
assert BurnRate.__tablename__ == "fin_burn_rates"
assert RunwayProjection.__tablename__ == "fin_runway_projections"
assert TokenSpend.__tablename__ == "fin_token_spends"
assert ServiceCost.__tablename__ == "fin_service_costs"

16
tests/test_runway.py Normal file
View 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