From 29dbaf1c2dd8d955b318ddad0301e814f5a96764 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 8 Jul 2026 22:46:24 +0200 Subject: [PATCH] Implement FIN-WP-0001-T06 HTTP runway read surface Add create_runway_router() following the hub-core factory pattern, expose GET /runway/summary and /runway/monthly-burn via create_app, and add finhub serve for local operation. --- .claude/rules/architecture.md | 6 +- .claude/rules/stack-and-commands.md | 5 + README.md | 1 + src/fin_hub/app.py | 33 ++++++ src/fin_hub/cli.py | 19 ++++ src/fin_hub/routers/__init__.py | 3 + src/fin_hub/routers/runway.py | 100 ++++++++++++++++++ src/fin_hub/schemas/__init__.py | 5 + src/fin_hub/schemas/runway.py | 40 +++++++ tests/test_api.py | 58 ++++++++++ .../FIN-WP-0001-runway-operations-lane.md | 4 +- 11 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 src/fin_hub/app.py create mode 100644 src/fin_hub/routers/__init__.py create mode 100644 src/fin_hub/routers/runway.py create mode 100644 src/fin_hub/schemas/__init__.py create mode 100644 src/fin_hub/schemas/runway.py create mode 100644 tests/test_api.py diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 7d8604b..3ee7677 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -16,6 +16,8 @@ fin-specific models and services live here. | `services/ledger.py` | SQLite cost ledger with monthly rollups | | `services/evaluate.py` | Ledger-driven runway evaluation for cron | | `services/evidence.py` | Non-secret dogfood evidence generation | +| `routers/runway.py` | `create_runway_router()` read factory (hub-core pattern) | +| `app.py` | FastAPI app factory (`create_app`) | | `cli.py` | Operator CLI (`finhub` entrypoint) | ### Data flow @@ -24,8 +26,8 @@ fin-specific models and services live here. CSV exports → ingest parsers → cost models → runway/alerts → coupling emit → State Hub progress ``` -v0.1 uses manual CSV import into a local SQLite ledger. HTTP read surface -remains deferred (`FIN-WP-0001-T06`). +v0.1 uses manual CSV import into a local SQLite ledger. HTTP read surface: +`GET /runway/summary` and `GET /runway/monthly-burn` via `finhub serve`. ## Quick Reference diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index f55fc13..68c8cdf 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -32,6 +32,11 @@ uv run finhub evaluate --emit # CLI — dogfood evidence artefact uv run finhub evidence --seed-fixtures +# HTTP read API +uv run finhub serve +# GET http://127.0.0.1:8080/runway/summary +# GET http://127.0.0.1:8080/runway/monthly-burn + # CLI — emit cross-hub signals (requires STATE_HUB_API) uv run finhub runway --balance 12000 --monthly-burn 2100 --emit ``` \ No newline at end of file diff --git a/README.md b/README.md index 41de6c1..824ce58 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ uv run finhub runway --balance 12000 --monthly-burn 2100,2200,2000 uv run finhub ledger import cloud tests/fixtures/cloud-costs.csv uv run finhub evaluate uv run finhub evidence --seed-fixtures +uv run finhub serve uv run finhub import-cloud tests/fixtures/cloud-costs.csv uv run finhub ops-costs tests/fixtures/hosteurope.csv ``` diff --git a/src/fin_hub/app.py b/src/fin_hub/app.py new file mode 100644 index 0000000..65ef36d --- /dev/null +++ b/src/fin_hub/app.py @@ -0,0 +1,33 @@ +"""Fin-hub FastAPI application factory.""" + +from __future__ import annotations + +from fastapi import FastAPI +from pydantic import BaseModel + +from fin_hub import __version__ +from fin_hub.routers import create_runway_router + + +class HealthResponse(BaseModel): + service: str + status: str + version: str + + +def create_app() -> FastAPI: + app = FastAPI( + title="Fin Hub API", + version=__version__, + description="Read surfaces for runway projection and monthly burn.", + ) + + @app.get("/healthz", response_model=HealthResponse, tags=["system"]) + def healthz() -> HealthResponse: + return HealthResponse(service="fin-hub", status="ok", version=__version__) + + app.include_router(create_runway_router()) + return app + + +app = create_app() \ No newline at end of file diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py index 5697025..0393558 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -136,6 +136,19 @@ def _cmd_evaluate(args: argparse.Namespace) -> int: return 0 +def _cmd_serve(args: argparse.Namespace) -> int: + import uvicorn + + uvicorn.run( + "fin_hub.app:create_app", + factory=True, + host=args.host, + port=args.port, + reload=args.reload, + ) + return 0 + + def _cmd_evidence(args: argparse.Namespace) -> int: output = write_runway_evidence( ledger_path=_ledger_path(args), @@ -227,6 +240,12 @@ def build_parser() -> argparse.ArgumentParser: ) evidence.set_defaults(func=_cmd_evidence) + serve = sub.add_parser("serve", help="Run the HTTP read API") + serve.add_argument("--host", default="127.0.0.1") + serve.add_argument("--port", type=int, default=8080) + serve.add_argument("--reload", action="store_true") + serve.set_defaults(func=_cmd_serve) + return parser diff --git a/src/fin_hub/routers/__init__.py b/src/fin_hub/routers/__init__.py new file mode 100644 index 0000000..0857014 --- /dev/null +++ b/src/fin_hub/routers/__init__.py @@ -0,0 +1,3 @@ +from fin_hub.routers.runway import create_runway_router + +__all__ = ["create_runway_router"] \ No newline at end of file diff --git a/src/fin_hub/routers/runway.py b/src/fin_hub/routers/runway.py new file mode 100644 index 0000000..cc7d9c1 --- /dev/null +++ b/src/fin_hub/routers/runway.py @@ -0,0 +1,100 @@ +"""Runway read router factory (hub-core pattern).""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query + +from fin_hub.schemas.runway import MonthlyBurnRead, RunwayAlertRead, RunwayProjectionRead, RunwaySummaryRead +from fin_hub.services.evaluate import evaluate_runway +from fin_hub.services.ledger import default_ledger_path, get_opening_balance, monthly_summary +LedgerPathResolver = Callable[[str | None], Path] + + +def _default_ledger_path_resolver(ledger: str | None) -> Path: + if ledger: + return Path(ledger) + env_path = os.environ.get("FIN_HUB_LEDGER") + if env_path: + return Path(env_path) + return default_ledger_path() + + +def _projection_from_dict(runway: dict) -> RunwayProjectionRead: + months = runway["months_remaining"] + if months == float("inf"): + months_remaining: float | None = None + label = "unlimited" + else: + months_remaining = float(months) + label = f"{months_remaining:.1f}" + return RunwayProjectionRead( + current_balance=float(runway["current_balance"]), + monthly_burn=float(runway["monthly_burn"]), + months_remaining=months_remaining, + months_remaining_label=label, + alert_threshold_months=float(runway["alert_threshold_months"]), + below_threshold=bool(runway["below_threshold"]), + currency=str(runway["currency"]), + computed_at=str(runway["computed_at"]), + ) + + +def create_runway_router( + *, + prefix: str = "/runway", + ledger_path_resolver: LedgerPathResolver | None = None, +) -> APIRouter: + resolve_ledger = ledger_path_resolver or _default_ledger_path_resolver + router = APIRouter(prefix=prefix, tags=["runway"]) + + @router.get("/monthly-burn", response_model=list[MonthlyBurnRead]) + def get_monthly_burn( + ledger: str | None = Query(default=None, description="Ledger database path override"), + currency: str | None = Query(default=None, description="Filter rollups by currency"), + ) -> list[MonthlyBurnRead]: + ledger_path = resolve_ledger(ledger) + if not ledger_path.exists(): + raise HTTPException(status_code=404, detail=f"Ledger not found: {ledger_path}") + rollups = monthly_summary(ledger_path=ledger_path) + if currency: + rollups = [rollup for rollup in rollups if rollup.currency == currency.upper()] + return [MonthlyBurnRead(**rollup.as_dict()) for rollup in rollups] + + @router.get("/summary", response_model=RunwaySummaryRead) + def get_runway_summary( + ledger: str | None = Query(default=None, description="Ledger database path override"), + balance: float | None = Query(default=None, description="Override stored opening balance"), + threshold: float = Query(default=3.0, ge=0.0), + currency: str = Query(default="EUR", min_length=3, max_length=3), + allocated: float | None = Query(default=None, ge=0.0), + spent: float | None = Query(default=None, ge=0.0), + ) -> RunwaySummaryRead: + ledger_path = resolve_ledger(ledger) + if not ledger_path.exists(): + raise HTTPException(status_code=404, detail=f"Ledger not found: {ledger_path}") + try: + report = evaluate_runway( + ledger_path=ledger_path, + opening_balance=balance, + alert_threshold_months=threshold, + currency=currency.upper(), + allocated=allocated, + spent=spent, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + stored_balance, stored_currency = get_opening_balance(ledger_path) + return RunwaySummaryRead( + runway=_projection_from_dict(report["runway"]), + alerts=[RunwayAlertRead(**alert) for alert in report["alerts"]], + opening_balance=balance if balance is not None else stored_balance, + opening_balance_currency=currency.upper() if balance is not None else stored_currency, + ledger_path=str(ledger_path.resolve()), + ) + + return router \ No newline at end of file diff --git a/src/fin_hub/schemas/__init__.py b/src/fin_hub/schemas/__init__.py new file mode 100644 index 0000000..9104043 --- /dev/null +++ b/src/fin_hub/schemas/__init__.py @@ -0,0 +1,5 @@ +"""Pydantic schemas for fin-hub HTTP surfaces.""" + +from fin_hub.schemas.runway import MonthlyBurnRead, RunwayAlertRead, RunwaySummaryRead + +__all__ = ["MonthlyBurnRead", "RunwayAlertRead", "RunwaySummaryRead"] \ No newline at end of file diff --git a/src/fin_hub/schemas/runway.py b/src/fin_hub/schemas/runway.py new file mode 100644 index 0000000..ce9f4f0 --- /dev/null +++ b/src/fin_hub/schemas/runway.py @@ -0,0 +1,40 @@ +"""Read schemas for runway and monthly burn HTTP surfaces.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class MonthlyBurnRead(BaseModel): + period_month: str + currency: str + total: float + entry_count: int + + +class RunwayProjectionRead(BaseModel): + current_balance: float + monthly_burn: float + months_remaining: float | None = Field( + description="Projected runway in months; null when burn is zero and balance is positive.", + ) + months_remaining_label: str + alert_threshold_months: float + below_threshold: bool + currency: str + computed_at: str + + +class RunwayAlertRead(BaseModel): + code: str + severity: str + summary: str + detail: dict + + +class RunwaySummaryRead(BaseModel): + runway: RunwayProjectionRead + alerts: list[RunwayAlertRead] + opening_balance: float | None + opening_balance_currency: str + ledger_path: str \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2112685 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,58 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from fin_hub.app import create_app +from fin_hub.routers.runway import create_runway_router +from fin_hub.services.evidence import seed_fixture_ledger + + +def _client_for_ledger(ledger: Path) -> TestClient: + app = create_app() + app.include_router( + create_runway_router(ledger_path_resolver=lambda _: ledger), + prefix="/test", + ) + return TestClient(app) + + +def test_runway_summary_and_monthly_burn_endpoints(tmp_path: Path): + ledger = tmp_path / "ledger.db" + seed_fixture_ledger(ledger_path=ledger, opening_balance=12000.0) + client = _client_for_ledger(ledger) + + burn_response = client.get("/test/runway/monthly-burn") + assert burn_response.status_code == 200 + burns = burn_response.json() + assert any(row["currency"] == "EUR" and row["period_month"] == "2026-06" for row in burns) + + summary_response = client.get("/test/runway/summary") + assert summary_response.status_code == 200 + summary = summary_response.json() + assert summary["opening_balance"] == 12000.0 + assert summary["runway"]["currency"] == "EUR" + assert summary["runway"]["monthly_burn"] > 0 + + +def test_runway_summary_requires_balance_when_unset(tmp_path: Path): + from fin_hub.services.ledger import monthly_summary + + ledger = tmp_path / "empty.db" + monthly_summary(ledger_path=ledger) + client = _client_for_ledger(ledger) + response = client.get("/test/runway/summary") + assert response.status_code == 422 + + +def test_runway_summary_404_when_ledger_missing(tmp_path: Path): + ledger = tmp_path / "missing.db" + client = _client_for_ledger(ledger) + response = client.get("/test/runway/summary?balance=12000") + assert response.status_code == 404 + + +def test_create_app_healthz(): + client = TestClient(create_app()) + response = client.get("/healthz") + assert response.status_code == 200 + assert response.json()["service"] == "fin-hub" \ No newline at end of file diff --git a/workplans/FIN-WP-0001-runway-operations-lane.md b/workplans/FIN-WP-0001-runway-operations-lane.md index 8612e15..5fe6684 100644 --- a/workplans/FIN-WP-0001-runway-operations-lane.md +++ b/workplans/FIN-WP-0001-runway-operations-lane.md @@ -4,7 +4,7 @@ type: workplan title: "Runway Operations and Federation Evidence" domain: infotech repo: fin-hub -status: active +status: finished owner: fin-hub topic_slug: infotech created: "2026-07-08" @@ -91,7 +91,7 @@ CSVs plus live ledger data. Reference from `docs/raas-mvp-packaging.md`. ```task id: FIN-WP-0001-T06 -status: wait +status: done priority: low state_hub_task_id: "40e46ee1-49ee-4c3f-ab6b-9471ea1ab3d9" ```