diff --git a/CLAUDE.custodian.md b/CLAUDE.custodian.md new file mode 100644 index 0000000..ef7d16a --- /dev/null +++ b/CLAUDE.custodian.md @@ -0,0 +1,32 @@ + + +# 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. diff --git a/README.md b/README.md index 815b69b..a6d0651 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/raas-mvp-packaging.md b/docs/raas-mvp-packaging.md new file mode 100644 index 0000000..8c8956b --- /dev/null +++ b/docs/raas-mvp-packaging.md @@ -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` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e1d2d7c..cd40a44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py new file mode 100644 index 0000000..8e09101 --- /dev/null +++ b/src/fin_hub/cli.py @@ -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()) \ No newline at end of file diff --git a/src/fin_hub/coupling/__init__.py b/src/fin_hub/coupling/__init__.py new file mode 100644 index 0000000..4f179de --- /dev/null +++ b/src/fin_hub/coupling/__init__.py @@ -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", +] \ No newline at end of file diff --git a/src/fin_hub/coupling/canon.py b/src/fin_hub/coupling/canon.py new file mode 100644 index 0000000..9a9258f --- /dev/null +++ b/src/fin_hub/coupling/canon.py @@ -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, + ) \ No newline at end of file diff --git a/src/fin_hub/coupling/common.py b/src/fin_hub/coupling/common.py new file mode 100644 index 0000000..254092b --- /dev/null +++ b/src/fin_hub/coupling/common.py @@ -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")) \ No newline at end of file diff --git a/src/fin_hub/coupling/dev_hub.py b/src/fin_hub/coupling/dev_hub.py new file mode 100644 index 0000000..3f78d52 --- /dev/null +++ b/src/fin_hub/coupling/dev_hub.py @@ -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 \ No newline at end of file diff --git a/src/fin_hub/coupling/ops_hub.py b/src/fin_hub/coupling/ops_hub.py new file mode 100644 index 0000000..ea71651 --- /dev/null +++ b/src/fin_hub/coupling/ops_hub.py @@ -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())), + } \ No newline at end of file diff --git a/src/fin_hub/ingest/__init__.py b/src/fin_hub/ingest/__init__.py new file mode 100644 index 0000000..2082a61 --- /dev/null +++ b/src/fin_hub/ingest/__init__.py @@ -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", +] \ No newline at end of file diff --git a/src/fin_hub/ingest/_csv.py b/src/fin_hub/ingest/_csv.py new file mode 100644 index 0000000..e32635f --- /dev/null +++ b/src/fin_hub/ingest/_csv.py @@ -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) \ No newline at end of file diff --git a/src/fin_hub/ingest/anthropic.py b/src/fin_hub/ingest/anthropic.py new file mode 100644 index 0000000..9128819 --- /dev/null +++ b/src/fin_hub/ingest/anthropic.py @@ -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 \ No newline at end of file diff --git a/src/fin_hub/ingest/cloud.py b/src/fin_hub/ingest/cloud.py new file mode 100644 index 0000000..35917eb --- /dev/null +++ b/src/fin_hub/ingest/cloud.py @@ -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 \ No newline at end of file diff --git a/src/fin_hub/ingest/hosteurope.py b/src/fin_hub/ingest/hosteurope.py new file mode 100644 index 0000000..c3078b0 --- /dev/null +++ b/src/fin_hub/ingest/hosteurope.py @@ -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 \ No newline at end of file diff --git a/src/fin_hub/models/__init__.py b/src/fin_hub/models/__init__.py index e320b9a..6bfd571 100644 --- a/src/fin_hub/models/__init__.py +++ b/src/fin_hub/models/__init__.py @@ -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", ] \ No newline at end of file diff --git a/src/fin_hub/models/service_cost.py b/src/fin_hub/models/service_cost.py new file mode 100644 index 0000000..e06d53b --- /dev/null +++ b/src/fin_hub/models/service_cost.py @@ -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) \ No newline at end of file diff --git a/src/fin_hub/services/__init__.py b/src/fin_hub/services/__init__.py new file mode 100644 index 0000000..382b98e --- /dev/null +++ b/src/fin_hub/services/__init__.py @@ -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"] \ No newline at end of file diff --git a/src/fin_hub/services/alerts.py b/src/fin_hub/services/alerts.py new file mode 100644 index 0000000..83a0c7a --- /dev/null +++ b/src/fin_hub/services/alerts.py @@ -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 \ No newline at end of file diff --git a/src/fin_hub/services/runway.py b/src/fin_hub/services/runway.py new file mode 100644 index 0000000..f74d6af --- /dev/null +++ b/src/fin_hub/services/runway.py @@ -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), + ) \ No newline at end of file diff --git a/tests/fixtures/anthropic-billing.csv b/tests/fixtures/anthropic-billing.csv new file mode 100644 index 0000000..dc05c65 --- /dev/null +++ b/tests/fixtures/anthropic-billing.csv @@ -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 \ No newline at end of file diff --git a/tests/fixtures/cloud-costs.csv b/tests/fixtures/cloud-costs.csv new file mode 100644 index 0000000..112cbe5 --- /dev/null +++ b/tests/fixtures/cloud-costs.csv @@ -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 \ No newline at end of file diff --git a/tests/fixtures/hosteurope.csv b/tests/fixtures/hosteurope.csv new file mode 100644 index 0000000..3f646bc --- /dev/null +++ b/tests/fixtures/hosteurope.csv @@ -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 \ No newline at end of file diff --git a/tests/test_coupling.py b/tests/test_coupling.py new file mode 100644 index 0000000..4585247 --- /dev/null +++ b/tests/test_coupling.py @@ -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) \ No newline at end of file diff --git a/tests/test_ingest.py b/tests/test_ingest.py new file mode 100644 index 0000000..a8c4b1d --- /dev/null +++ b/tests/test_ingest.py @@ -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" \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 82fa08f..fece89d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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" \ No newline at end of file + assert TokenSpend.__tablename__ == "fin_token_spends" + assert ServiceCost.__tablename__ == "fin_service_costs" \ No newline at end of file diff --git a/tests/test_runway.py b/tests/test_runway.py new file mode 100644 index 0000000..0002d18 --- /dev/null +++ b/tests/test_runway.py @@ -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 \ No newline at end of file